From 257bf588a68d8d9a2017579214d0c0850cd46a66 Mon Sep 17 00:00:00 2001 From: Locrian24 Date: Mon, 27 Jul 2026 22:47:56 -0700 Subject: [PATCH 1/3] Port sqm-partner-info-modal early partner creation flow to 1.x Hand-port the partner-info modal and connectionStatus tax/cash wiring from cash-reward-redemption, adapted for 1.x Shoelace theming without 2.x BrandingConfig or stateController preview infrastructure. --- packages/mint-components/src/components.d.ts | 366 +++++++++++++++++- .../src/components/sqm-form-message/readme.md | 2 + .../ImpactConnectionStatus.feature | 30 ++ .../PartnerInfoModal.stories.tsx | 210 ++++++++++ .../sqm-partner-info-modal/readme.md | 53 +++ .../sqm-partner-info-modal-view.tsx | 298 ++++++++++++++ .../sqm-partner-info-modal.feature | 158 ++++++++ .../sqm-partner-info-modal.tsx | 261 +++++++++++++ .../usePartnerInfoModal.tsx | 349 +++++++++++++++++ .../sqm-widget-verification/keys.ts | 2 + .../sqm-widget-verification.feature | 37 +- .../sqm-widget-verification.tsx | 226 +++++++++-- .../useWidgetVerification.ts | 59 ++- .../tax-and-cash/TaxForm.stories.tsx | 5 + .../tax-and-cash/phoneExtensions.ts | 12 + .../sqm-indirect-tax-form/readme.md | 66 ++-- .../sqm-indirect-tax-form-view.tsx | 7 +- .../sqm-indirect-tax-form.tsx | 3 +- .../useIndirectTaxForm.tsx | 172 +++++--- .../tax-and-cash/sqm-tax-and-cash/data.ts | 14 +- .../sqm-tax-and-cash/useTaxAndCash.tsx | 124 +++++- .../tax-and-cash/sqm-user-info-form/readme.md | 70 ++-- .../small-views/IndirectTaxDetailsView.tsx | 4 +- .../sqm-user-info-form-view.tsx | 152 +++++--- .../sqm-user-info-form/sqm-user-info-form.tsx | 2 + .../sqm-user-info-form/useUserInfoForm.tsx | 182 ++++++++- .../src/components/tax-and-cash/utils.ts | 70 ++++ packages/mint-components/src/saasquatch.d.ts | 1 + 28 files changed, 2669 insertions(+), 266 deletions(-) create mode 100644 packages/mint-components/src/components/sqm-partner-info-modal/ImpactConnectionStatus.feature create mode 100644 packages/mint-components/src/components/sqm-partner-info-modal/PartnerInfoModal.stories.tsx create mode 100644 packages/mint-components/src/components/sqm-partner-info-modal/readme.md create mode 100644 packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal-view.tsx create mode 100644 packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.feature create mode 100644 packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.tsx create mode 100644 packages/mint-components/src/components/sqm-partner-info-modal/usePartnerInfoModal.tsx diff --git a/packages/mint-components/src/components.d.ts b/packages/mint-components/src/components.d.ts index 96104bccc3..9ca2e181c1 100644 --- a/packages/mint-components/src/components.d.ts +++ b/packages/mint-components/src/components.d.ts @@ -33,6 +33,7 @@ import { NameFieldsViewProps } from "./components/sqm-name-fields/sqm-name-field import { NavigationMenuViewProps } from "./components/sqm-navigation-menu/sqm-navigation-menu-view"; import { NavigationSidebarItemViewProps } from "./components/sqm-navigation-sidebar-item/sqm-navigation-sidebar-item-view"; import { UsePagination } from "./components/sqm-pagination/usePagination"; +import { PartnerInfoModalResult } from "./components/sqm-partner-info-modal/usePartnerInfoModal"; import { PasswordFieldViewDemoProps } from "./components/sqm-password-field/sqm-password-field"; import { PayoutButtonScrollViewProps } from "./components/sqm-payout-button-scroll/sqm-payout-button-scroll-view"; import { PayoutStatusAlertViewProps } from "./components/tax-and-cash/sqm-payout-status-alert/sqm-payout-status-alert-view"; @@ -1929,6 +1930,103 @@ export namespace Components { */ "paginationText": string; } + interface SqmPartnerInfoModal { + /** + * Edit the property called terms and conditions text to change what's displayed for {termsAndConditionsLink}. + * @uiName Terms and conditions checkbox + */ + "allowBankingCollection": string; + /** + * @uiName Confirm button label + */ + "confirmButtonLabel": string; + /** + * @uiName Country label + */ + "countryLabel": string; + /** + * @uiName Currency label + */ + "currencyLabel": string; + /** + * @undocumented + * @uiType object + */ + "demoData"?: DemoData; + /** + * Description for existing partner confirmation + * @uiName Existing partner description + * @uiWidget textArea + */ + "descriptionExistingPartner": string; + /** + * Description for new partner setup + * @uiName New partner description + * @uiWidget textArea + */ + "descriptionNewPartner": string; + /** + * Used to render in another modal. + * @undocumented + */ + "inModal": boolean; + /** + * @uiName Missing fields error text + * @uiWidget textArea + */ + "missingFieldsErrorText": string; + /** + * Header text when user has no existing partner + * @uiName New partner header + * @uiWidget textArea + */ + "modalHeader": string; + /** + * Header text when user has an existing partner + * @uiName Existing partner header + * @uiWidget textArea + */ + "modalHeaderExistingPartner": string; + /** + * @uiName Network error text + * @uiWidget textArea + */ + "networkErrorText": string; + /** + * @uiName Search country placeholder + */ + "searchCountryPlaceholder": string; + /** + * @uiName Search currency placeholder + */ + "searchCurrencyPlaceholder": string; + /** + * @uiName Submit button label + */ + "submitButtonLabel": string; + /** + * Support description for existing partner confirmation + * @uiName Existing partner support description + * @uiWidget textArea + */ + "supportDescriptionExistingPartner": string; + /** + * Support description for existing partner confirmation + * @uiName Existing partner support description + */ + "supportLink": string; + /** + * The link text that appears in the terms and conditions checkbox + * @uiName Terms and conditions text + * @uiWidget textArea + */ + "termsAndConditionsLabel": string; + /** + * The link that appears in the terms and conditions checkbox + * @uiName Terms and conditions link + */ + "termsAndConditionsLink": string; + } interface SqmPasswordField { /** * @undocumented @@ -6261,6 +6359,78 @@ export namespace Components { * @uiGroup Code Verification Step */ "codeStep_verifyText": string; + /** + * @uiName Confirm button label + * @uiGroup Partner Creation Step + */ + "createPartnerStep_confirmButtonLabel": string; + /** + * @uiName Country label + * @uiGroup Partner Creation Step + */ + "createPartnerStep_countryLabel": string; + /** + * @uiName Currency label + * @uiGroup Partner Creation Step + */ + "createPartnerStep_currencyLabel": string; + /** + * @uiName Existing partner description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_descriptionExistingPartner": string; + /** + * @uiName New partner description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_descriptionNewPartner": string; + /** + * @uiName Missing fields error text + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_missingFieldsErrorText": string; + /** + * @uiName New partner header + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_modalHeader": string; + /** + * @uiName Existing partner header + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_modalHeaderExistingPartner": string; + /** + * @uiName Network error text + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_networkErrorText": string; + /** + * @uiName Search country placeholder + * @uiGroup Partner Creation Step + */ + "createPartnerStep_searchCountryPlaceholder": string; + /** + * @uiName Search currency placeholder + * @uiGroup Partner Creation Step + */ + "createPartnerStep_searchCurrencyPlaceholder": string; + /** + * @uiName Submit button label + * @uiGroup Partner Creation Step + */ + "createPartnerStep_submitButtonLabel": string; + /** + * @uiName Existing partner support description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_supportDescriptionExistingPartner": string; /** * @uiName Email input label * @uiGroup Email Verification Step @@ -6292,15 +6462,10 @@ export namespace Components { */ "emailStep_verifyEmailHeaderText": string; /** - * @uiName General verify widget description text - * @uiGroup General Text - */ - "general_verifyEmailDescription": string; - /** - * @uiName General verify widget header text + * @uiName General widget header text with partner creation * @uiGroup General Text */ - "general_verifyEmailHeader": string; + "general_widgetHeaderWithPartnerCreation": string; } interface SqmWidgetVerificationController { } @@ -6600,6 +6765,12 @@ declare global { prototype: HTMLSqmPaginationElement; new (): HTMLSqmPaginationElement; }; + interface HTMLSqmPartnerInfoModalElement extends Components.SqmPartnerInfoModal, HTMLStencilElement { + } + var HTMLSqmPartnerInfoModalElement: { + prototype: HTMLSqmPartnerInfoModalElement; + new (): HTMLSqmPartnerInfoModalElement; + }; interface HTMLSqmPasswordFieldElement extends Components.SqmPasswordField, HTMLStencilElement { } var HTMLSqmPasswordFieldElement: { @@ -7118,6 +7289,7 @@ declare global { "sqm-navigation-sidebar": HTMLSqmNavigationSidebarElement; "sqm-navigation-sidebar-item": HTMLSqmNavigationSidebarItemElement; "sqm-pagination": HTMLSqmPaginationElement; + "sqm-partner-info-modal": HTMLSqmPartnerInfoModalElement; "sqm-password-field": HTMLSqmPasswordFieldElement; "sqm-payout-button-scroll": HTMLSqmPayoutButtonScrollElement; "sqm-payout-details-card": HTMLSqmPayoutDetailsCardElement; @@ -9062,6 +9234,103 @@ declare namespace LocalJSX { */ "paginationText"?: string; } + interface SqmPartnerInfoModal { + /** + * Edit the property called terms and conditions text to change what's displayed for {termsAndConditionsLink}. + * @uiName Terms and conditions checkbox + */ + "allowBankingCollection"?: string; + /** + * @uiName Confirm button label + */ + "confirmButtonLabel"?: string; + /** + * @uiName Country label + */ + "countryLabel"?: string; + /** + * @uiName Currency label + */ + "currencyLabel"?: string; + /** + * @undocumented + * @uiType object + */ + "demoData"?: DemoData; + /** + * Description for existing partner confirmation + * @uiName Existing partner description + * @uiWidget textArea + */ + "descriptionExistingPartner"?: string; + /** + * Description for new partner setup + * @uiName New partner description + * @uiWidget textArea + */ + "descriptionNewPartner"?: string; + /** + * Used to render in another modal. + * @undocumented + */ + "inModal"?: boolean; + /** + * @uiName Missing fields error text + * @uiWidget textArea + */ + "missingFieldsErrorText"?: string; + /** + * Header text when user has no existing partner + * @uiName New partner header + * @uiWidget textArea + */ + "modalHeader"?: string; + /** + * Header text when user has an existing partner + * @uiName Existing partner header + * @uiWidget textArea + */ + "modalHeaderExistingPartner"?: string; + /** + * @uiName Network error text + * @uiWidget textArea + */ + "networkErrorText"?: string; + /** + * @uiName Search country placeholder + */ + "searchCountryPlaceholder"?: string; + /** + * @uiName Search currency placeholder + */ + "searchCurrencyPlaceholder"?: string; + /** + * @uiName Submit button label + */ + "submitButtonLabel"?: string; + /** + * Support description for existing partner confirmation + * @uiName Existing partner support description + * @uiWidget textArea + */ + "supportDescriptionExistingPartner"?: string; + /** + * Support description for existing partner confirmation + * @uiName Existing partner support description + */ + "supportLink"?: string; + /** + * The link text that appears in the terms and conditions checkbox + * @uiName Terms and conditions text + * @uiWidget textArea + */ + "termsAndConditionsLabel"?: string; + /** + * The link that appears in the terms and conditions checkbox + * @uiName Terms and conditions link + */ + "termsAndConditionsLink"?: string; + } interface SqmPasswordField { /** * @undocumented @@ -13369,6 +13638,78 @@ declare namespace LocalJSX { * @uiGroup Code Verification Step */ "codeStep_verifyText"?: string; + /** + * @uiName Confirm button label + * @uiGroup Partner Creation Step + */ + "createPartnerStep_confirmButtonLabel"?: string; + /** + * @uiName Country label + * @uiGroup Partner Creation Step + */ + "createPartnerStep_countryLabel"?: string; + /** + * @uiName Currency label + * @uiGroup Partner Creation Step + */ + "createPartnerStep_currencyLabel"?: string; + /** + * @uiName Existing partner description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_descriptionExistingPartner"?: string; + /** + * @uiName New partner description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_descriptionNewPartner"?: string; + /** + * @uiName Missing fields error text + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_missingFieldsErrorText"?: string; + /** + * @uiName New partner header + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_modalHeader"?: string; + /** + * @uiName Existing partner header + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_modalHeaderExistingPartner"?: string; + /** + * @uiName Network error text + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_networkErrorText"?: string; + /** + * @uiName Search country placeholder + * @uiGroup Partner Creation Step + */ + "createPartnerStep_searchCountryPlaceholder"?: string; + /** + * @uiName Search currency placeholder + * @uiGroup Partner Creation Step + */ + "createPartnerStep_searchCurrencyPlaceholder"?: string; + /** + * @uiName Submit button label + * @uiGroup Partner Creation Step + */ + "createPartnerStep_submitButtonLabel"?: string; + /** + * @uiName Existing partner support description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + "createPartnerStep_supportDescriptionExistingPartner"?: string; /** * @uiName Email input label * @uiGroup Email Verification Step @@ -13400,15 +13741,10 @@ declare namespace LocalJSX { */ "emailStep_verifyEmailHeaderText"?: string; /** - * @uiName General verify widget description text - * @uiGroup General Text - */ - "general_verifyEmailDescription"?: string; - /** - * @uiName General verify widget header text + * @uiName General widget header text with partner creation * @uiGroup General Text */ - "general_verifyEmailHeader"?: string; + "general_widgetHeaderWithPartnerCreation"?: string; } interface SqmWidgetVerificationController { } @@ -13462,6 +13798,7 @@ declare namespace LocalJSX { "sqm-navigation-sidebar": SqmNavigationSidebar; "sqm-navigation-sidebar-item": SqmNavigationSidebarItem; "sqm-pagination": SqmPagination; + "sqm-partner-info-modal": SqmPartnerInfoModal; "sqm-password-field": SqmPasswordField; "sqm-payout-button-scroll": SqmPayoutButtonScroll; "sqm-payout-details-card": SqmPayoutDetailsCard; @@ -13595,6 +13932,7 @@ declare module "@stencil/core" { "sqm-navigation-sidebar": LocalJSX.SqmNavigationSidebar & JSXBase.HTMLAttributes; "sqm-navigation-sidebar-item": LocalJSX.SqmNavigationSidebarItem & JSXBase.HTMLAttributes; "sqm-pagination": LocalJSX.SqmPagination & JSXBase.HTMLAttributes; + "sqm-partner-info-modal": LocalJSX.SqmPartnerInfoModal & JSXBase.HTMLAttributes; "sqm-password-field": LocalJSX.SqmPasswordField & JSXBase.HTMLAttributes; "sqm-payout-button-scroll": LocalJSX.SqmPayoutButtonScroll & JSXBase.HTMLAttributes; "sqm-payout-details-card": LocalJSX.SqmPayoutDetailsCard & JSXBase.HTMLAttributes; diff --git a/packages/mint-components/src/components/sqm-form-message/readme.md b/packages/mint-components/src/components/sqm-form-message/readme.md index 53260ee6d7..27a8d0869f 100644 --- a/packages/mint-components/src/components/sqm-form-message/readme.md +++ b/packages/mint-components/src/components/sqm-form-message/readme.md @@ -22,6 +22,7 @@ - [sqm-edit-profile](../sqm-edit-profile) - [sqm-instant-access-registration](../sqm-instant-access-registration) - [sqm-lead-form](../sqm-lead-form) + - [sqm-partner-info-modal](../sqm-partner-info-modal) - [sqm-portal-change-marketing](../sqm-portal-change-marketing) - [sqm-portal-change-password](../sqm-portal-change-password) - [sqm-portal-email-verification](../sqm-portal-email-verification) @@ -45,6 +46,7 @@ graph TD; sqm-edit-profile --> sqm-form-message sqm-instant-access-registration --> sqm-form-message sqm-lead-form --> sqm-form-message + sqm-partner-info-modal --> sqm-form-message sqm-portal-change-marketing --> sqm-form-message sqm-portal-change-password --> sqm-form-message sqm-portal-email-verification --> sqm-form-message diff --git a/packages/mint-components/src/components/sqm-partner-info-modal/ImpactConnectionStatus.feature b/packages/mint-components/src/components/sqm-partner-info-modal/ImpactConnectionStatus.feature new file mode 100644 index 0000000000..cb237916f2 --- /dev/null +++ b/packages/mint-components/src/components/sqm-partner-info-modal/ImpactConnectionStatus.feature @@ -0,0 +1,30 @@ +@owner:andy @author:andy +Feature: Partner Connection Status + + Background: + Given user is on the Microsite or a widget that contains the cash form + + Scenario: New participants with no publisher submits partner modal + Given a participant with no linked Impact publisher + When they submit the partner info modal with valid country, currency, and T&C consent + Then an Impact publisher is created for the participant + And impactConnection.connectionStatus resolves to "STARTED" + + Scenario: New participants with an existing publisher submits partner modal + Given a participant whose email is already linked to an Impact publisher + When they submit the partner info modal with valid country, currency, and T&C consent + Then the existing Impact publisher is used and not updated + And impactConnection.connectionStatus resolves to "STARTED" + + Scenario: User has not filled out User Info Form + Given the user has a publisher + And the publisher does not contain billing data + When they submit the user User Info Form with their billing information + Then impactConnection.connectionStatus resolves to "COMPLETED" + + Scenario: User has pre-existing partner which completed user info form + Given the user has a publisher + And the publisher contains complete billing data + Then the user info form is skipped + And the data is unchanged + And impactConnection.connectionStatus resolves to "COMPLETED" diff --git a/packages/mint-components/src/components/sqm-partner-info-modal/PartnerInfoModal.stories.tsx b/packages/mint-components/src/components/sqm-partner-info-modal/PartnerInfoModal.stories.tsx new file mode 100644 index 0000000000..96c14a0c2b --- /dev/null +++ b/packages/mint-components/src/components/sqm-partner-info-modal/PartnerInfoModal.stories.tsx @@ -0,0 +1,210 @@ +import { h } from "@stencil/core"; +import { + PartnerInfoModalView, + PartnerInfoModalViewProps, +} from "./sqm-partner-info-modal-view"; + +export default { + title: "Components/Partner Info Modal", +}; + +const demoCountries = [ + { countryCode: "US", displayName: "United States" }, + { countryCode: "CA", displayName: "Canada" }, + { countryCode: "GB", displayName: "United Kingdom" }, + { countryCode: "AU", displayName: "Australia" }, + { countryCode: "DE", displayName: "Germany" }, + { countryCode: "FR", displayName: "France" }, + { countryCode: "JP", displayName: "Japan" }, +]; + +const demoCurrencies = [ + { currencyCode: "USD", displayName: "US Dollar" }, + { currencyCode: "CAD", displayName: "Canadian Dollar" }, + { currencyCode: "GBP", displayName: "British Pound" }, + { currencyCode: "EUR", displayName: "Euro" }, + { currencyCode: "AUD", displayName: "Australian Dollar" }, +]; + +const noopCallbacks = { + onCountryChange: (e: any) => console.log("Country changed:", e), + onCurrencyChange: (e: any) => console.log("Currency changed:", e), + onCheckboxChange: (e: any) => console.log("Checkbox changed:", e), + setCountrySearch: (v: string) => console.log("Country search:", v), + setCurrencySearch: (v: string) => console.log("Currency search:", v), + onSubmit: () => console.log("Submit"), + onClose: () => console.log("Close"), +}; + +const defaultText = { + modalHeader: "Let's get you ready for rewards", + modalHeaderExistingPartner: "We found an existing account", + descriptionNewPartner: + "Confirm your country and currency now to get your future rewards faster.", + descriptionExistingPartner: + "We noticed you are already an Impact.com partner, please confirm your information.", + supportDescriptionExistingPartner: + "If this is a mistake, please contact Support or sign up for this referral program with a different email.", + supportLink: "Support", + countryLabel: "Country", + currencyLabel: "Currency", + submitButtonLabel: "Submit", + confirmButtonLabel: "Confirm", + searchCountryPlaceholder: "Search for a country", + searchCurrencyPlaceholder: "Search for a currency", + bankingCollectionText: "", + allowBankingCollection: + "I have read the {termsAndConditionsLink} and allow impact.com to collect my tax and banking information", + termsAndConditionsLabel: "terms and conditions", + termsAndConditionsLink: + "https://terms.advocate.impact.com/PayoutTermsAndConditions.html", +}; + +const defaultProps: PartnerInfoModalViewProps = { + states: { + open: true, + loading: false, + submitting: false, + isExistingPartner: false, + countryCode: "", + currency: "", + error: "", + success: false, + filteredCountries: demoCountries, + filteredCurrencies: demoCurrencies, + allowBankingCollection: false, + disabled: false, + }, + callbacks: noopCallbacks, + text: defaultText, +}; + +export const NewPartnerEmpty = () => { + return ; +}; + +export const NewPartnerPrefilled = () => { + const props: PartnerInfoModalViewProps = { + ...defaultProps, + states: { + ...defaultProps.states, + countryCode: "US", + currency: "", + }, + }; + return ; +}; + +export const NewPartnerFullySelected = () => { + const props: PartnerInfoModalViewProps = { + ...defaultProps, + states: { + ...defaultProps.states, + countryCode: "US", + currency: "USD", + }, + }; + return ; +}; + +export const ExistingPartnerConfirm = () => { + const props: PartnerInfoModalViewProps = { + ...defaultProps, + states: { + ...defaultProps.states, + isExistingPartner: true, + countryCode: "CA", + currency: "CAD", + }, + }; + return ; +}; + +export const ExistingPartnerEmpty = () => { + const props: PartnerInfoModalViewProps = { + ...defaultProps, + states: { + ...defaultProps.states, + isExistingPartner: true, + countryCode: "", + currency: "", + }, + }; + return ; +}; + +export const Submitting = () => { + const props: PartnerInfoModalViewProps = { + ...defaultProps, + states: { + ...defaultProps.states, + countryCode: "GB", + currency: "GBP", + submitting: true, + }, + }; + return ; +}; + +export const WithError = () => { + const props: PartnerInfoModalViewProps = { + ...defaultProps, + states: { + ...defaultProps.states, + countryCode: "US", + currency: "USD", + error: + "An error occurred while creating your partner account. Please try again.", + }, + }; + return ; +}; + +export const ValidationError = () => { + const props: PartnerInfoModalViewProps = { + ...defaultProps, + states: { + ...defaultProps.states, + countryCode: "", + currency: "", + error: "Please select both a country and currency.", + }, + }; + return ; +}; + +export const Closed = () => { + const props: PartnerInfoModalViewProps = { + ...defaultProps, + states: { + ...defaultProps.states, + open: false, + }, + }; + return ; +}; + +export const SQMComponentExistingPartner = () => { + return ( + + ); +}; + +export const SQMComponentNewPartner = () => { + return ( + + ); +}; diff --git a/packages/mint-components/src/components/sqm-partner-info-modal/readme.md b/packages/mint-components/src/components/sqm-partner-info-modal/readme.md new file mode 100644 index 0000000000..27f95c2f75 --- /dev/null +++ b/packages/mint-components/src/components/sqm-partner-info-modal/readme.md @@ -0,0 +1,53 @@ +# sqm-partner-info-modal + + + + + + +## Properties + +| Property | Attribute | Description | Type | Default | +| ----------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allowBankingCollection` | `allow-banking-collection` | Edit the property called terms and conditions text to change what's displayed for {termsAndConditionsLink}. | `string` | `"I have read the {termsAndConditionsLink} and allow impact.com to collect my tax and banking information."` | +| `confirmButtonLabel` | `confirm-button-label` | | `string` | `"Confirm"` | +| `countryLabel` | `country-label` | | `string` | `"Country"` | +| `currencyLabel` | `currency-label` | | `string` | `"Currency"` | +| `demoData` | -- | | `{ states?: { open: boolean; loading: boolean; submitting: boolean; isExistingPartner: boolean; countryCode: string; currency: string; error: string; success: boolean; filteredCountries: { countryCode: string; displayName: string; }[]; filteredCurrencies: { currencyCode: string; displayName: string; }[]; allowBankingCollection: boolean; disabled: boolean; }; }` | `undefined` | +| `descriptionExistingPartner` | `description-existing-partner` | Description for existing partner confirmation | `string` | `"We found an account with this email on our referral program provider, impact.com. Please confirm your country and currency now to get your future rewards faster."` | +| `descriptionNewPartner` | `description-new-partner` | Description for new partner setup | `string` | `"Confirm your country and currency now to get your future rewards faster."` | +| `inModal` | `in-modal` | Used to render in another modal. | `boolean` | `false` | +| `missingFieldsErrorText` | `missing-fields-error-text` | | `string` | `"Please fill out all fields."` | +| `modalHeader` | `modal-header` | Header text when user has no existing partner | `string` | `"Let's get you ready for rewards"` | +| `modalHeaderExistingPartner` | `modal-header-existing-partner` | Header text when user has an existing partner | `string` | `"We found an existing account"` | +| `networkErrorText` | `network-error-text` | | `string` | `"An error occurred. Please try again."` | +| `searchCountryPlaceholder` | `search-country-placeholder` | | `string` | `"Search for a country"` | +| `searchCurrencyPlaceholder` | `search-currency-placeholder` | | `string` | `"Search for a currency"` | +| `submitButtonLabel` | `submit-button-label` | | `string` | `"Submit"` | +| `supportDescriptionExistingPartner` | `support-description-existing-partner` | Support description for existing partner confirmation | `string` | `"If this is a mistake, please contact {supportLink} or sign up for this referral program with a different email."` | +| `supportLink` | `support-link` | Support description for existing partner confirmation | `string` | `"Support"` | +| `termsAndConditionsLabel` | `terms-and-conditions-label` | The link text that appears in the terms and conditions checkbox | `string` | `"terms and conditions"` | +| `termsAndConditionsLink` | `terms-and-conditions-link` | The link that appears in the terms and conditions checkbox | `string` | `"https://terms.advocate.impact.com/PayoutTermsAndConditions.html"` | + + +## Dependencies + +### Used by + + - [sqm-widget-verification](../sqm-widget-verification) + +### Depends on + +- [sqm-form-message](../sqm-form-message) + +### Graph +```mermaid +graph TD; + sqm-partner-info-modal --> sqm-form-message + sqm-widget-verification --> sqm-partner-info-modal + style sqm-partner-info-modal fill:#f9f,stroke:#333,stroke-width:4px +``` + +---------------------------------------------- + +*Built with [StencilJS](https://stenciljs.com/)* diff --git a/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal-view.tsx b/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal-view.tsx new file mode 100644 index 0000000000..4b0e352e39 --- /dev/null +++ b/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal-view.tsx @@ -0,0 +1,298 @@ +import { h } from "@stencil/core"; +import { createStyleSheet } from "../../styling/JSS"; +import { intl } from "../../global/global"; + +export interface PartnerInfoModalViewProps { + states: { + open: boolean; + loading: boolean; + submitting: boolean; + isExistingPartner: boolean; + countryCode: string; + currency: string; + error: string; + success: boolean; + filteredCountries: { countryCode: string; displayName: string }[]; + filteredCurrencies: { currencyCode: string; displayName: string }[]; + allowBankingCollection: boolean; + disabled: boolean; + }; + callbacks: { + onCountryChange: (e: any) => void; + onCurrencyChange: (e: any) => void; + onCheckboxChange: (e: any) => void; + setCurrencySearch: (c: any) => void; + setCountrySearch: (c: any) => void; + onSubmit: () => void; + onClose: () => void; + onInitialFocus?: (e: any) => void; + }; + text: { + modalHeader: string; + descriptionNewPartner: string; + descriptionExistingPartner: string; + countryLabel: string; + currencyLabel: string; + submitButtonLabel: string; + confirmButtonLabel: string; + searchCountryPlaceholder: string; + searchCurrencyPlaceholder: string; + supportDescriptionExistingPartner: string; + supportLink: string; + modalHeaderExistingPartner: string; + allowBankingCollection: string; + termsAndConditionsLabel: string; + termsAndConditionsLink: string; + }; +} + +const style = { + Dialog: { + "&::part(panel)": { + maxWidth: "480px", + }, + "&::part(body)": { + padding: "0 var(--sl-spacing-x-large)", + fontSize: "var(--sl-font-size-small)", + overflow: "visible", + }, + "&::part(overlay)": { + background: "var(--sl-overlay-background-color)", + }, + }, + DialogTitle: { + fontSize: "var(--sl-font-size-x-large)", + fontWeight: "600", + padding: "var(--sl-spacing-large) 0 0 0", + margin: "0", + }, + FormFields: { + display: "flex", + flexDirection: "column", + gap: "var(--sl-spacing-medium)", + marginTop: "var(--sl-spacing-large)", + }, + ErrorMessage: { + marginTop: "var(--sl-spacing-x-small)", + }, + SearchInput: { + "&::part(base)": { + border: "none", + borderBottom: "1px solid var(--sl-color-neutral-300)", + borderRadius: "0", + }, + }, + SelectMenu: { + maxHeight: "300px", + }, + DescriptionContainer: { + display: "flex", + flexDirection: "column", + gap: "var(--sl-spacing-medium)", + margin: "0", + "& > p": { + margin: "0", + }, + }, + CheckboxWrapper: { + display: "flex", + justifyContent: "flex-start", + flexDirection: "column", + }, + Checkbox: { + "&::part(label)": { + fontSize: "var(--sl-font-size-small)", + }, + }, + // Inherit 1.x Shoelace primary theming via type="primary" (no 2.x --sqm-primary-button-* tokens) + SubmitButton: { + width: "100%", + marginTop: "var(--sl-spacing-large)", + }, +}; + +export function PartnerInfoModalContentView(props: PartnerInfoModalViewProps) { + const { states, callbacks, text } = props; + const sheet = createStyleSheet(style); + const styleString = sheet.toString(); + + const supportMessage = intl.formatMessage( + { + id: "supportDescriptionExistingPartner", + defaultMessage: text.supportDescriptionExistingPartner, + }, + { + supportLink: ( + + {text.supportLink} + + ), + }, + ); + + const description = states.isExistingPartner ? ( + +

{text.descriptionExistingPartner}

+

{supportMessage}

+
+ ) : ( +

+ {text.descriptionNewPartner} +

+ ); + + const buttonLabel = states.isExistingPartner + ? text.confirmButtonLabel + : text.submitButtonLabel; + + const bankingCollectionText = intl.formatMessage( + { + id: "bankingCollectionText", + defaultMessage: text.allowBankingCollection, + }, + { + termsAndConditionsLink: ( + + {text.termsAndConditionsLabel} + + ), + }, + ); + + return ( +
+ +
+ {description} + + e.stopPropagation()} + onSl-input={(e: any) => { + callbacks.setCountrySearch(e.target?.value); + }} + /> +
+ {states.filteredCountries?.map((c) => ( + {c.displayName} + ))} +
+
+ + + e.stopPropagation()} + onSl-input={(e: any) => + callbacks.setCurrencySearch(e.target?.value) + } + /> +
+ {states.filteredCurrencies?.map((c) => ( + + {c.currencyCode} - {c.displayName} + + ))} +
+
+
+ + {bankingCollectionText} + +
+
+ {states.error && ( +
+ +

{states.error}

+
+
+ )} + + + {buttonLabel} + +
+ ); +} + +export function PartnerInfoModalView(props: PartnerInfoModalViewProps) { + const { states, text, callbacks } = props; + const sheet = createStyleSheet(style); + const styleString = sheet.toString(); + + if (!states.open) return
; + + const modalHeader = states.isExistingPartner + ? text.modalHeaderExistingPartner + : text.modalHeader; + + return ( +
+ + { + e.preventDefault(); + }} + onSl-hide={(e: any) => { + if (e.target?.tagName === "SL-DIALOG") { + e.preventDefault(); + } + }} + onSl-initial-focus={callbacks.onInitialFocus} + > +

{modalHeader}

+ +
+
+ ); +} diff --git a/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.feature b/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.feature new file mode 100644 index 0000000000..4ebc4d05aa --- /dev/null +++ b/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.feature @@ -0,0 +1,158 @@ +@owner:andy @author:andy +Feature: Partner Info Modal — country, currency, and T&C collection + As a cash-program operator + I want a participant's country, currency, and Tax-&-Banking consent collected + before they can access cash features + So that the partner record created in impact.com is valid from the start. + + Background: + Given a participant whose `impactConnection.connectionStatus` is "NOT_STARTED" + + @motivating + Scenario: Modal is shown when the connection has not been started + Given the user data has loaded + And `impactConnection.connectionStatus` is "NOT_STARTED" + Then the partner-info modal is open + + @motivating + Scenario: Modal is hidden once the connection has been started + Given `impactConnection.connectionStatus` does not equal "NOT_STARTED" + Then the partner-info modal is not rendered + + @minutia + Scenario: Modal is hidden while user data is still loading + Given the GET_USER_PARTNER_INFO query has not resolved yet + Then the partner-info modal is not rendered + + @minutia + Scenario: Modal cannot be dismissed by the user + Given the partner-info modal is open + When the user presses Escape, clicks the overlay, or otherwise tries to close the dialog + Then the close is prevented (sl-request-close + sl-hide are intercepted) + And the modal stays open until a successful submission + + @motivating + Scenario Outline: Country defaults to the locale-derived country code for a new participant + Given the participant has no linked Impact publisher + And the resolved locale is + Then the "Country" select is prefilled with + And the "Country" select is enabled + And the "Currency" select starts empty and is enabled + + Examples: + | locale | countryCode | + | en_US | US | + | fr_CA | CA | + | en_GB | GB | + + @motivating + Scenario: New participant with no connection + Given a new participant with no linked publisher + When they open the "Country" select + Then they see only countries returned by `impactPayoutCountries` + When they select a country + Then the "Currency" select is reset to empty + And the "Currency" select shows only currencies supported by the selected country + + @motivating + Scenario: User has existing partner and modal prefills country + currency + Given the participant is already linked to an Impact publisher + And that publisher has a `countryCode` of "US" and a `currency` of "USD" + When the modal opens + Then the "Country" select is prefilled with "US" and disabled + And the "Currency" select is prefilled with "USD" and disabled + And the modal header uses the existing-partner copy + And the description uses the existing-partner copy (including the support email link) + And the primary button label uses the existing-partner "Confirm" copy + + @motivating + Scenario: Existing-partner with linked publisher missing country/currency falls back to the new-partner defaults + Given the participant is linked to an existing publisher + But the publisher has no `countryCode` or `currency` + Then the "Country" select defaults to the locale-derived country code (and is enabled) + And the "Currency" select starts empty (and is enabled) + And the "Confirm" button submits the locale/user selections via `startImpactConnection` + + @motivating + Scenario: Tax-and-banking checkbox is required to submit + Given the partner-info modal is open + And the "Country" select has a value + And the "Currency" select has a value + But the "Tax and banking" checkbox is unchecked + Then the primary button is disabled + When the user checks the "Tax and banking" checkbox + Then the primary button becomes enabled + + @minutia + Scenario: Submit button is disabled + Given the partner-info modal is open + When any one of (country, currency, allowBankingCollection) is missing + Then the submit button is disabled + + @motivating + Scenario: Submitting the modal starts the Impact connection and closes the modal + Given the user has selected country "US", currency "USD", and checked the T&C checkbox + When they click the primary button + Then the modal calls the `startImpactConnection` mutation with + | user.id | the participant id | + | user.accountId | the participant accountId | + | firstName | from the user record | + | lastName | from the user record | + | countryCode | "US" | + | currency | "USD" | + And on success `impactConnection.connectionStatus` changes from "NOT_STARTED" to "STARTED" + And the modal closes + + @motivating + Scenario: Existing-partner submission attaches the participant to the linked publisher + Given the participant is linked to an existing publisher with country "CA" and currency "CAD" + And the T&C checkbox is checked + When they click "Confirm" + Then `startImpactConnection` is called with the publisher's country "CA" and currency "CAD" + And on success the connection advances to "STARTED" and the modal closes + + @motivating + Scenario Outline: Submission failure leaves the modal open with a recoverable error + Given the partner-info modal is open with all fields valid + When `startImpactConnection` fails with + Then the modal stays open + And `error` is set to + And the an impactConnection is not created + + Examples: + | failure | errorText | + | a thrown / network error | the `networkErrorText` prop | + | a result with `success: false` + errors | the joined `validationErrors[].message` strings | + | a result with `success: false` no errors | the `networkErrorText` prop | + + @minutia + Scenario: All inputs are disabled while submitting + Given the partner-info modal is open + When the user clicks the primary button and the mutation is in flight + Then the "Country", "Currency", and "Tax and banking" inputs are all disabled + And the primary button shows a loading spinner + + @minutia + Scenario: Country select has a search input that filters the displayed countries + Given the "Country" select is open + When the user types into the search input + Then the displayed countries is filtered to countries whose `displayName` contains the search text (case-insensitive) + + @minutia + Scenario: Currency select has a search input that filters the displayed currencies + Given the "Currency" select is open + When the user types into the search input + Then the displayed currencies is filtered to currencies whose `currencyCode` contains the search text (case-insensitive) + + @motivating + Scenario Outline: A user is filling out the form and selects their currency + Given a user with countryCode + When they open the currency dropdown + Then only are displayed + And USD, AUD, EUR, GBP are displayed regardless of country code + + Examples: + | countryCode | currencies | + | USA | USD, AUD, EUR, GBP | + | CAN | USD, AUD, EUR, GBP, CAN | + | IND | USD, AUD, EUR, GBP, INR | diff --git a/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.tsx b/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.tsx new file mode 100644 index 0000000000..c2077a4704 --- /dev/null +++ b/packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.tsx @@ -0,0 +1,261 @@ +import { isDemo } from "@saasquatch/component-boilerplate"; +import { useState, withHooks } from "@saasquatch/stencil-hooks"; +import { Component, Prop, h } from "@stencil/core"; +import deepmerge from "deepmerge"; +import { DemoData } from "../../global/demo"; +import { getProps } from "../../utils/utils"; +import { + PartnerInfoModalContentView, + PartnerInfoModalView, + PartnerInfoModalViewProps, +} from "./sqm-partner-info-modal-view"; +import { + PartnerInfoModalResult, + usePartnerInfoModal, +} from "./usePartnerInfoModal"; + +/** + * @uiName Partner Info Modal + * @exampleGroup Tax and Cash + * @validParents ["sqm-portal-container", "sqm-portal-frame", "div", "sqm-divided-layout", "sqb-program-section", "sqb-conditional-section"] + * @example Partner Info Modal - + * @hidden + */ +@Component({ + tag: "sqm-partner-info-modal", + shadow: true, +}) +export class PartnerInfoModal { + /** + * Header text when user has no existing partner + * + * @uiName New partner header + * @uiWidget textArea + */ + @Prop() + modalHeader: string = "Let's get you ready for rewards"; + + /** + * Header text when user has an existing partner + * + * @uiName Existing partner header + * @uiWidget textArea + */ + @Prop() + modalHeaderExistingPartner: string = "We found an existing account"; + + /** + * Description for new partner setup + * + * @uiName New partner description + * @uiWidget textArea + */ + @Prop() + descriptionNewPartner: string = + "Confirm your country and currency now to get your future rewards faster."; + + /** + * Description for existing partner confirmation + * + * @uiName Existing partner description + * @uiWidget textArea + */ + @Prop() + descriptionExistingPartner: string = + "We found an account with this email on our referral program provider, impact.com. Please confirm your country and currency now to get your future rewards faster."; + + /** + * Support description for existing partner confirmation + * + * @uiName Existing partner support description + * @uiWidget textArea + */ + @Prop() + supportDescriptionExistingPartner: string = + "If this is a mistake, please contact {supportLink} or sign up for this referral program with a different email."; + + /** + * Support description for existing partner confirmation + * + * @uiName Existing partner support description + */ + @Prop() + supportLink: string = "Support"; + + /** + * Edit the property called terms and conditions text to change what's displayed for {termsAndConditionsLink}. + * @uiName Terms and conditions checkbox + */ + @Prop() allowBankingCollection: string = + "I have read the {termsAndConditionsLink} and allow impact.com to collect my tax and banking information."; + /** + * The link text that appears in the terms and conditions checkbox + * @uiName Terms and conditions text + * @uiWidget textArea + */ + @Prop() termsAndConditionsLabel: string = "terms and conditions"; + /** + * The link that appears in the terms and conditions checkbox + * @uiName Terms and conditions link + */ + @Prop() termsAndConditionsLink: string = + "https://terms.advocate.impact.com/PayoutTermsAndConditions.html"; + /** + * @uiName Country label + */ + @Prop() + countryLabel: string = "Country"; + + /** + * @uiName Currency label + */ + @Prop() + currencyLabel: string = "Currency"; + + /** + * @uiName Submit button label + */ + @Prop() + submitButtonLabel: string = "Submit"; + + /** + * @uiName Confirm button label + */ + @Prop() + confirmButtonLabel: string = "Confirm"; + + /** + * @uiName Search country placeholder + */ + @Prop() + searchCountryPlaceholder: string = "Search for a country"; + + /** + * @uiName Search currency placeholder + */ + @Prop() + searchCurrencyPlaceholder: string = "Search for a currency"; + + /** + * @uiName Network error text + * @uiWidget textArea + */ + @Prop() + networkErrorText: string = "An error occurred. Please try again."; + + /** + * @uiName Missing fields error text + * @uiWidget textArea + */ + @Prop() + missingFieldsErrorText: string = "Please fill out all fields."; + + /** + * Used to render in another modal. + * @undocumented + */ + @Prop() inModal: boolean = false; + + /** + * @undocumented + * @uiType object + */ + @Prop() demoData?: DemoData; + + constructor() { + withHooks(this); + } + disconnectedCallback() {} + + getTextProps() { + return getProps(this); + } + + render() { + const props = isDemo() + ? useDemoPartnerInfoModal(this) + : usePartnerInfoModal(this); + + if (this.inModal) { + return ; + } + + return ; + } +} + +function useDemoPartnerInfoModal( + props: PartnerInfoModal, +): PartnerInfoModalViewProps { + const [countryCode, setCountryCode] = useState("US"); + const [currency, setCurrency] = useState(""); + const [error, setError] = useState(""); + const [allowBankingCollection, setAllowBankingCollection] = useState(false); + + // @ts-ignore + return deepmerge( + { + states: { + open: false, + loading: false, + submitting: false, + isExistingPartner: false, + countryCode, + currency, + error, + success: false, + filteredCountries: [], + filteredCurrencies: [], + allowBankingCollection, + disabled: false, + }, + callbacks: { + onCountryChange: (e: any) => { + const value = e?.detail?.item?.__value; + if (value) { + setCountryCode(value); + setCurrency(""); + } + }, + onCurrencyChange: (e: any) => { + const value = e?.detail?.item?.__value; + if (value) setCurrency(value); + }, + onCheckboxChange: (e: any) => { + setAllowBankingCollection(e.target.checked); + }, + setCountrySearch: () => {}, + setCurrencySearch: () => {}, + onSubmit: () => { + if (!countryCode || !currency) { + setError(props.missingFieldsErrorText); + return; + } + setError(""); + }, + onClose: () => {}, + onInitialFocus: (e: any) => e.preventDefault(), + }, + text: { + modalHeader: props.modalHeader, + descriptionNewPartner: props.descriptionNewPartner, + descriptionExistingPartner: props.descriptionExistingPartner, + countryLabel: props.countryLabel, + currencyLabel: props.currencyLabel, + submitButtonLabel: props.submitButtonLabel, + confirmButtonLabel: props.confirmButtonLabel, + searchCountryPlaceholder: props.searchCountryPlaceholder, + searchCurrencyPlaceholder: props.searchCurrencyPlaceholder, + supportDescriptionExistingPartner: + props.supportDescriptionExistingPartner, + supportLink: props.supportLink, + modalHeaderExistingPartner: props.modalHeaderExistingPartner, + allowBankingCollection: props.allowBankingCollection, + termsAndConditionsLabel: props.termsAndConditionsLabel, + termsAndConditionsLink: props.termsAndConditionsLink, + }, + }, + props.demoData || {}, + { arrayMerge: (_, a) => a }, + ); +} diff --git a/packages/mint-components/src/components/sqm-partner-info-modal/usePartnerInfoModal.tsx b/packages/mint-components/src/components/sqm-partner-info-modal/usePartnerInfoModal.tsx new file mode 100644 index 0000000000..682f433cbb --- /dev/null +++ b/packages/mint-components/src/components/sqm-partner-info-modal/usePartnerInfoModal.tsx @@ -0,0 +1,349 @@ +import { + useLocale, + useMutation, + useQuery, + useSetParent, +} from "@saasquatch/component-boilerplate"; +import { useEffect, useMemo, useState } from "@saasquatch/universal-hooks"; +import { gql } from "graphql-request"; +import { PartnerInfoModal } from "./sqm-partner-info-modal"; +import { PartnerInfoModalViewProps } from "./sqm-partner-info-modal-view"; +import { StartImpactConnectionResult } from "../tax-and-cash/sqm-indirect-tax-form/useIndirectTaxForm"; +import { TAX_FORM_UPDATED_EVENT_KEY } from "../tax-and-cash/eventKeys"; +import { PARTNER_CREATED_NAMESPACE } from "../sqm-widget-verification/keys"; +import { + GET_FINANCE_NETWORK_SETTINGS, + FinanceNetworkSettingsQuery, +} from "../tax-and-cash/sqm-tax-and-cash/data"; + +export const GET_USER_PARTNER_INFO = gql` + query getUserPartnerInfo { + user: viewer { + ... on User { + id + accountId + firstName + lastName + email + countryCode + customFields + impactConnection { + connected + connectionStatus + publisher { + id + countryCode + currency + } + } + } + } + } +`; + +export const GET_COUNTRIES = gql` + query getCountries { + impactPayoutCountries(limit: 1000) { + data { + countryCode + displayName + } + } + } +`; + +export const GET_CURRENCIES = gql` + query currencies($locale: RSLocale) { + currencies(limit: 300) { + data { + displayName(locale: $locale) + currencyCode + } + } + } +`; + +const START_IMPACT_CONNECTION = gql` + mutation startImpactConnection($vars: ImpactConnectionInput!) { + startImpactConnection(impactConnectionInput: $vars) { + success + validationErrors { + field + message + } + user { + id + accountId + impactConnection { + connected + publisher { + brandedSignup + requiredTaxDocumentType + currentTaxDocument { + type + status + } + } + } + } + } + } +`; + +export type TaxCountry = { + countryCode: string; + displayName: string; +}; + +export type CountriesQuery = { + impactPayoutCountries: { + data: TaxCountry[]; + }; +}; + +export function usePartnerInfoModal( + props: PartnerInfoModal, +): PartnerInfoModalViewProps { + const locale = useLocale(); + + const setPartnerCreated = useSetParent(PARTNER_CREATED_NAMESPACE); + + const { + data: userData, + loading: userLoading, + refetch, + } = useQuery(GET_USER_PARTNER_INFO, {}); + + const user = userData?.user; + + const { data: currenciesData, loading: currenciesLoading } = useQuery( + GET_CURRENCIES, + { variables: { locale } }, + ); + + const { data: countriesData, loading: countriesLoading } = useQuery( + GET_COUNTRIES, + {}, + ); + + // No pre-filled country, use locale to determine countryCode instead + const [countryCode, setCountryCode] = useState( + user?.impactConnection?.publisher?.countryCode || + locale.replace(/^.*_/, ""), + ); + + const [currency, setCurrency] = useState( + user?.impactConnection?.publisher?.currency || "", + ); + + const { data: financeNetworkData } = useQuery( + GET_FINANCE_NETWORK_SETTINGS, + { + variables: { filter: countryCode ? { countryCode_eq: countryCode } : {} }, + }, + ); + + const [startImpactConnection, { loading: connectLoading }] = + useMutation(START_IMPACT_CONNECTION); + + const [allowBankingCollection, setAllowBankingCollection] = useState(false); + + const countries = useMemo(() => { + const data = countriesData?.impactPayoutCountries?.data; + if (!data) return []; + return [...data].sort((a: TaxCountry, b: TaxCountry) => { + if (a.countryCode === "US") return -1; + if (b.countryCode === "US") return 1; + return a.displayName.localeCompare(b.displayName); + }); + }, [countriesData]); + + const isExistingPartner = !!user?.impactConnection?.publisher; + + const _currencies = useMemo(() => { + // if isExistingPartner, grab all currencies since select is disabled anyways + if (isExistingPartner) { + return currenciesData?.currencies?.data || []; + } + + const allValidCurrencies = + financeNetworkData?.impactFinanceNetworkSettings?.data?.reduce( + (agg, settings) => { + if (countryCode && settings.countryCode !== countryCode) return agg; + const c = currenciesData?.currencies?.data?.find( + (cur) => cur.currencyCode === settings.currency, + ); + if (!c) return agg; + if (agg.find((cur) => cur.currencyCode === settings.currency)) + return agg; + return [...agg, c]; + }, + [], + ); + return allValidCurrencies || []; + }, [financeNetworkData, currenciesData, countryCode, isExistingPartner]); + + const currencies = useMemo( + () => + [..._currencies].sort((a, b) => + a.displayName.localeCompare(b.displayName), + ), + [_currencies], + ); + + const [countrySearch, setCountrySearch] = useState(""); + const [currencySearch, setCurrencySearch] = useState(""); + const [filteredCountries, setFilteredCountries] = useState(countries || []); + const [filteredCurrencies, setFilteredCurrencies] = useState( + currencies || [], + ); + + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + + useEffect(() => { + const publisher = user?.impactConnection?.publisher; + if (!userData || !publisher) return; + setCountryCode(publisher.countryCode); + setCurrency(publisher.currency); + }, [userData, user]); + + useEffect(() => { + if (!countries?.length) return; + if (countrySearch.trim() === "") { + setFilteredCountries(countries || []); + } else { + setFilteredCountries( + countries.filter((c) => + c.displayName.toLowerCase().includes(countrySearch.toLowerCase()), + ) || [], + ); + } + }, [countrySearch, countries]); + + useEffect(() => { + if (!currencies?.length) return; + if (currencySearch.trim() === "") { + setFilteredCurrencies(currencies || []); + } else { + setFilteredCurrencies( + currencies.filter((c) => + c.currencyCode.toLowerCase().includes(currencySearch.toLowerCase()), + ) || [], + ); + } + }, [currencySearch, currencies]); + + const impactConnection = user?.impactConnection; + + function onCountryChange(e: any) { + const value = e.detail?.item?.__value; + if (!value) return; + setCountryCode(value); + setCurrency(""); + setError(""); + } + + function onCurrencyChange(e: any) { + const value = e.detail?.item?.__value; + if (!value) return; + setCurrency(value); + setError(""); + } + + function onCheckboxChange(e: any) { + const checked = e.target.checked; + setAllowBankingCollection(checked); + } + + async function onSubmit() { + if (!allowBankingCollection || !countryCode || !currency) { + setError(props.missingFieldsErrorText); + return; + } + setError(""); + + if (!user) { + setError(props.networkErrorText); + return; + } + + try { + const vars = { + user: { + id: user.id, + accountId: user.accountId, + }, + firstName: user.firstName, + lastName: user.lastName, + countryCode, + currency, + }; + + const result = await startImpactConnection({ vars }); + + if (!result || (result as Error)?.message) { + setError(props.networkErrorText); + return; + } + + const connectionResult = (result as StartImpactConnectionResult) + .startImpactConnection; + + if (!connectionResult?.success) { + const validationMsg = connectionResult?.validationErrors + ?.map((e) => e.message) + .join(". "); + setError(validationMsg || props.networkErrorText); + console.error( + "Failed to create Impact connection:", + connectionResult?.validationErrors, + ); + return; + } + + window.dispatchEvent(new Event(TAX_FORM_UPDATED_EVENT_KEY)); + + await refetch(); + setPartnerCreated?.(true); + setSuccess(true); + } catch (e) { + console.error("Partner creation error:", e); + setError(props.networkErrorText); + } + } + + const showModal = + !success && + !userLoading && + impactConnection?.connectionStatus === "NOT_STARTED"; + + return { + states: { + open: showModal, + loading: userLoading || countriesLoading || currenciesLoading, + submitting: connectLoading, + isExistingPartner, + countryCode, + currency, + error, + success, + filteredCountries: filteredCountries || [], + filteredCurrencies: filteredCurrencies || [], + allowBankingCollection, + disabled: userLoading || connectLoading, + }, + callbacks: { + onCountryChange, + onCurrencyChange, + onCheckboxChange, + setCurrencySearch, + setCountrySearch, + onSubmit, + onClose: () => setSuccess(true), + }, + text: props.getTextProps(), + }; +} + +export type PartnerInfoModalResult = ReturnType; diff --git a/packages/mint-components/src/components/sqm-widget-verification/keys.ts b/packages/mint-components/src/components/sqm-widget-verification/keys.ts index c46ba9d8cf..43876614ef 100644 --- a/packages/mint-components/src/components/sqm-widget-verification/keys.ts +++ b/packages/mint-components/src/components/sqm-widget-verification/keys.ts @@ -1,4 +1,6 @@ export const SHOW_CODE_NAMESPACE = "sq:code-verification"; +export const SHOW_PARTNER_MODAL_NAMESPACE = "sq:show-partner-modal"; +export const PARTNER_CREATED_NAMESPACE = "sq:partner-created"; export const VERIFICATION_EMAIL_NAMESPACE = "sq:verification-email"; export const VERIFICATION_PARENT_NAMESPACE = "sq:verification-context"; export const VERIFICATION_EVENT_KEY = "sq:code-verified"; diff --git a/packages/mint-components/src/components/sqm-widget-verification/sqm-widget-verification.feature b/packages/mint-components/src/components/sqm-widget-verification/sqm-widget-verification.feature index 4679279759..8035975d72 100644 --- a/packages/mint-components/src/components/sqm-widget-verification/sqm-widget-verification.feature +++ b/packages/mint-components/src/components/sqm-widget-verification/sqm-widget-verification.feature @@ -11,10 +11,13 @@ Feature: Widget verification flow """ When the html is loaded - Then the page displays the widget verification flow + Then the page displays the widget verification flow inside a non-dismissable sl-dialog + And the dialog title is "Let's get you ready for rewards" When an email is successfully verified + Then the partner info modal step is displayed inside the same dialog + When the partner info modal is completed successfully Then the html inside the "verified" template is displayed - And the widget verification flow is hidden + And the widget verification flow dialog is hidden @motivating @ui Scenario: Entering an email for verification @@ -40,6 +43,36 @@ Feature: Widget verification flow Then an email containing a 2FA is sent to the provided email address And the component displays the code verification step + @motivating + Scenario: Successful code verification opens the partner info modal step in the same dialog + Given a user on the code verification step + When they enter a valid 2FA code + And they click "Verify" + Then the partner creation step is displayed inside the same widget verification dialog + And the embedded `sqm-partner-info-modal` is rendered with the `inModal` prop + And the dialog title remains "Let's get you ready for rewards" + And the dialog cannot be dismissed by clicking the overlay or pressing Escape + + @motivating + Scenario: Successful partner creation completes the verification flow + Given the partner info modal step is displayed + When the user successfully submits the partner info modal + Then the widget verification dialog is hidden + And the html inside the "verified" template slot is displayed + + @motivating + Scenario Outline: Initial load skips ahead based on the user's verification and connection state + Given a user with `emailVerified` + And `impactConnection.connected` + When the `sqm-widget-verification` component loads + Then the is shown + + Examples: + | emailVerified | connected | step | + | false | false | email verification step | + | true | false | partner info modal step (email/code skipped) | + | true | true | verified slot (entire flow skipped) | + @motivating @ui Scenario: Code verification step Given a user on the code verification step diff --git a/packages/mint-components/src/components/sqm-widget-verification/sqm-widget-verification.tsx b/packages/mint-components/src/components/sqm-widget-verification/sqm-widget-verification.tsx index 2b2d294b04..171d4ee45a 100644 --- a/packages/mint-components/src/components/sqm-widget-verification/sqm-widget-verification.tsx +++ b/packages/mint-components/src/components/sqm-widget-verification/sqm-widget-verification.tsx @@ -9,10 +9,42 @@ import { getProps } from "../../utils/utils"; import { extractProps } from "../tax-and-cash/sqm-tax-and-cash/extractProps"; import { SHOW_CODE_NAMESPACE, + SHOW_PARTNER_MODAL_NAMESPACE, VERIFICATION_EMAIL_NAMESPACE, VERIFICATION_PARENT_NAMESPACE, } from "./keys"; import { useWidgetVerification } from "./useWidgetVerification"; +import { createStyleSheet } from "../../styling/JSS"; + +const style = { + Dialog: { + "&::part(panel)": { + maxWidth: "480px", + }, + "&::part(body)": { + padding: "0 var(--sl-spacing-x-large)", + fontSize: "var(--sl-font-size-small)", + overflow: "visible", + }, + "&::part(footer)": { + display: "flex", + flexDirection: "column", + gap: "var(--sl-spacing-small)", + }, + "&::part(overlay)": { + background: "rgba(0, 0, 0, 0.5)", + }, + }, + DialogTitle: { + fontSize: "var(--sl-font-size-x-large)", + fontWeight: "600", + padding: "var(--sl-spacing-x-large) 0 0 0", + margin: "0", + }, +}; + +const sheet = createStyleSheet(style); +const styleString = sheet.toString(); /** * @uiName Widget Verification Flow @@ -24,23 +56,16 @@ import { useWidgetVerification } from "./useWidgetVerification"; shadow: true, }) export class WidgetVerification { - // ! Any updated must be reflected in sqm-widget-verification-internal AND sqm-email-verification AND sqm-code-verification + // ! Any updated must be reflected in sqm-widget-verification-internal AND sqm-email-verification AND sqm-code-verification AND sqm-partner-info-modal /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ GENERAL PROPS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ /** - * @uiName General verify widget header text - * @uiGroup General Text - */ - @Prop() - general_verifyEmailHeader = "Verify your email"; - /** - * @uiName General verify widget description text + * @uiName General widget header text with partner creation * @uiGroup General Text */ @Prop() - general_verifyEmailDescription = - "To get your cash paid out directly to your bank account, please complete your account setup"; + general_widgetHeaderWithPartnerCreation = "Let's get you ready for rewards"; /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ EMAIL STEP PROPS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ @@ -85,7 +110,7 @@ export class WidgetVerification { emailStep_emailValidationErrorText: string = "Please enter a valid email"; /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - EMAIL STEP PROPS + CODE STEP PROPS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ /** * @uiName Verify code widget header text @@ -139,6 +164,101 @@ export class WidgetVerification { @Prop() codeStep_networkErrorMessage: string = "An error occurred while verifying your email. Please refresh the page and try again."; + /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + PARTNER CREATION STEP PROPS + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ + /** + * @uiName New partner header + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + @Prop() + createPartnerStep_modalHeader: string = "Let's get you ready for rewards"; + /** + * @uiName Existing partner header + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + @Prop() + createPartnerStep_modalHeaderExistingPartner: string = + "We found an existing account"; + /** + * @uiName New partner description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + @Prop() + createPartnerStep_descriptionNewPartner: string = + "Confirm your country and currency now to get your future rewards faster."; + /** + * @uiName Existing partner description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + @Prop() + createPartnerStep_descriptionExistingPartner: string = + "We found an account with this email on our referral program provider, impact.com. Please confirm your country and currency now to get your future rewards faster."; + /** + * @uiName Existing partner support description + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + @Prop() + createPartnerStep_supportDescriptionExistingPartner: string = + "If this is a mistake, please contact Support or sign up for this referral program with a different email."; + /** + * @uiName Country label + * @uiGroup Partner Creation Step + */ + @Prop() + createPartnerStep_countryLabel: string = "Country"; + /** + * @uiName Currency label + * @uiGroup Partner Creation Step + */ + @Prop() + createPartnerStep_currencyLabel: string = "Currency"; + /** + * @uiName Submit button label + * @uiGroup Partner Creation Step + */ + @Prop() + createPartnerStep_submitButtonLabel: string = "Submit"; + /** + * @uiName Confirm button label + * @uiGroup Partner Creation Step + */ + @Prop() + createPartnerStep_confirmButtonLabel: string = "Confirm"; + /** + * @uiName Search country placeholder + * @uiGroup Partner Creation Step + */ + @Prop() + createPartnerStep_searchCountryPlaceholder: string = "Search for a country"; + /** + * @uiName Search currency placeholder + * @uiGroup Partner Creation Step + */ + @Prop() + createPartnerStep_searchCurrencyPlaceholder: string = "Search for a currency"; + /** + * @uiName Network error text + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + @Prop() + createPartnerStep_networkErrorText: string = + "An error occurred. Please try again."; + /** + * @uiName Missing fields error text + * @uiGroup Partner Creation Step + * @uiWidget textArea + */ + @Prop() + createPartnerStep_missingFieldsErrorText: string = + "Please select both a country and currency."; + constructor() { withHooks(this); } @@ -150,49 +270,75 @@ export class WidgetVerification { } render() { - const { showCode, onVerification, loading } = isDemo() + const props = isDemo() ? useDemoWidgetVerificationInternal() : useWidgetVerification(); - if (loading) return ; + if (props.loading) return ; + + const partnerText = this.getStepTextProps("createPartnerStep_"); - const generalText = this.getStepTextProps("general_"); + const dialogLabel = this.general_widgetHeaderWithPartnerCreation; + + const renderStepContent = () => { + if (props.showPartnerModal) { + return ( + + ); + } + if (props.showCode) { + return ( + + ); + } + return ( + + ); + }; return (
-

- {generalText.verifyEmailHeader} -

-

{styleString} + { + e.preventDefault(); + }} + onSl-hide={(e: any) => { + if (e.target?.tagName === "SL-DIALOG") { + e.preventDefault(); + } }} > - {generalText.verifyEmailDescription} -

- {showCode ? ( - - ) : ( - - )} +

{dialogLabel}

+ {renderStepContent()} +
); } } function useDemoWidgetVerificationInternal() { - const [showCode, setShowCode] = useParentState({ + const [showCode] = useParentState({ namespace: SHOW_CODE_NAMESPACE, initialValue: false, }); - const [email, setEmail] = useParentState({ + const [showPartnerModal] = useParentState({ + namespace: SHOW_PARTNER_MODAL_NAMESPACE, + initialValue: false, + }); + useParentState({ namespace: VERIFICATION_EMAIL_NAMESPACE, initialValue: undefined, }); @@ -202,5 +348,11 @@ function useDemoWidgetVerificationInternal() { setContext(true); }; - return { showCode, onVerification, loading: false }; + return { + showCode, + showPartnerModal, + onVerification, + onPartnerModalComplete: () => {}, + loading: false, + }; } diff --git a/packages/mint-components/src/components/sqm-widget-verification/useWidgetVerification.ts b/packages/mint-components/src/components/sqm-widget-verification/useWidgetVerification.ts index ec7442ce05..b3056b3161 100644 --- a/packages/mint-components/src/components/sqm-widget-verification/useWidgetVerification.ts +++ b/packages/mint-components/src/components/sqm-widget-verification/useWidgetVerification.ts @@ -2,13 +2,14 @@ import { useLazyQuery, useParentState, useSetParent, - useUserIdentity, } from "@saasquatch/component-boilerplate"; import { useState } from "@saasquatch/stencil-hooks"; import { useEffect } from "@saasquatch/universal-hooks"; import { gql } from "graphql-request"; import { + PARTNER_CREATED_NAMESPACE, SHOW_CODE_NAMESPACE, + SHOW_PARTNER_MODAL_NAMESPACE, VERIFICATION_EMAIL_NAMESPACE, VERIFICATION_PARENT_NAMESPACE, } from "./keys"; @@ -25,23 +26,36 @@ const USER_LOOKUP = gql` email emailVerified } + impactConnection { + connected + } } } } `; export function useWidgetVerification() { - const userIdentity = useUserIdentity(); - const [showCode, setShowCode] = useParentState({ + const [showCode] = useParentState({ namespace: SHOW_CODE_NAMESPACE, initialValue: false, }); - const [email, setEmail] = useParentState({ + + useParentState({ namespace: VERIFICATION_EMAIL_NAMESPACE, - initialValue: userIdentity?.email, + initialValue: undefined, }); + const setContext = useSetParent(VERIFICATION_PARENT_NAMESPACE); const [loading, setLoading] = useState(true); + const [showPartnerModal, setShowPartnerModal] = useParentState({ + namespace: SHOW_PARTNER_MODAL_NAMESPACE, + initialValue: false, + }); + + const [partnerCreated, setPartnerCreated] = useParentState({ + namespace: PARTNER_CREATED_NAMESPACE, + initialValue: false, + }); const [fetch] = useLazyQuery(USER_LOOKUP); useEffect(() => { @@ -50,8 +64,19 @@ export function useWidgetVerification() { const res = await fetch({}); if (!res || res instanceof Error) throw new Error(); - if (res?.viewer?.emailVerified) setContext(true); - else if (res?.viewer?.managedIdentity?.emailVerified) setContext(true); + // Flow changed to send email -> verify code -> show early partner creation modal + const emailVerified = + res?.viewer?.emailVerified || + res?.viewer?.managedIdentity?.emailVerified; + const isConnected = res?.viewer?.impactConnection?.connected; + + if (isConnected) { + // Partner already created, show widget content + setContext(true); + } else if (emailVerified) { + // Email verified but no partner yet, show partner modal + setShowPartnerModal(true); + } } catch (e) { console.error("Could not fetch user information:", e); } finally { @@ -63,8 +88,26 @@ export function useWidgetVerification() { }, []); const onVerification = () => { + setShowPartnerModal(true); + }; + + const onPartnerModalComplete = () => { + setShowPartnerModal(false); + // Reset the latch so a later partner creation can re-trigger this effect + setPartnerCreated(false); setContext(true); }; - return { showCode, onVerification, loading }; + // The modal signals completion by flipping partnerCreated; advance the flow here + useEffect(() => { + if (partnerCreated) onPartnerModalComplete(); + }, [partnerCreated]); + + return { + showCode, + showPartnerModal, + onVerification, + onPartnerModalComplete, + loading, + }; } diff --git a/packages/mint-components/src/components/tax-and-cash/TaxForm.stories.tsx b/packages/mint-components/src/components/tax-and-cash/TaxForm.stories.tsx index 71a38bed00..479144fd18 100644 --- a/packages/mint-components/src/components/tax-and-cash/TaxForm.stories.tsx +++ b/packages/mint-components/src/components/tax-and-cash/TaxForm.stories.tsx @@ -30,6 +30,8 @@ const stepOneProps: StoryDemoData = { disabled: false, isPartner: false, isUser: false, + isPartnerLegacy: false, + isUserLegacy: false, loadingError: false, formState: { firstName: "Bob", @@ -49,6 +51,8 @@ const stepOneProps: StoryDemoData = { data: { regionLabelEnum: "STATE", regions: [], + partnerData: undefined, + userData: undefined, phoneCountries: [ { countryCode: "CA", @@ -146,6 +150,7 @@ const stepTwoProps: StoryDemoData = { disabled: false, loading: false, isPartner: false, + isPartnerLegacy: false, loadingError: false, formState: { checked: undefined, diff --git a/packages/mint-components/src/components/tax-and-cash/phoneExtensions.ts b/packages/mint-components/src/components/tax-and-cash/phoneExtensions.ts index 5e29f65dfe..0961ce8a75 100644 --- a/packages/mint-components/src/components/tax-and-cash/phoneExtensions.ts +++ b/packages/mint-components/src/components/tax-and-cash/phoneExtensions.ts @@ -219,6 +219,10 @@ export const PHONE_EXTENSIONS = { name: "Cuba", dial_code: "+53", }, + CW: { + name: "Curaçao", + dial_code: "+599", + }, CY: { name: "Cyprus", dial_code: "+357", @@ -299,6 +303,10 @@ export const PHONE_EXTENSIONS = { name: "French Polynesia", dial_code: "+689", }, + TF: { + name: "French Southern Territories", + dial_code: "+262", + }, GA: { name: "Gabon", dial_code: "+241", @@ -955,6 +963,10 @@ export const PHONE_EXTENSIONS = { name: "Wallis and Futuna", dial_code: "+681", }, + EH: { + name: "Western Sahara", + dial_code: "+212", + }, YE: { name: "Yemen", dial_code: "+967", diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/readme.md b/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/readme.md index b72e95dbc0..680c7c8918 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/readme.md +++ b/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/readme.md @@ -5,39 +5,39 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| -------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `backButton` | `back-button` | | `string` | `"Back"` | -| `cannotChangeInfoAlert` | `cannot-change-info-alert` | Communicate that after this step, only Support can change personal and indirect tax information. | `string` | `"Changes to your personal and indirect tax information can only be made through our {supportLink} after you complete this step. Make sure these are correct before continuing."` | -| `continueButton` | `continue-button` | | `string` | `"Continue"` | -| `demoData` | -- | | `{ states?: { step: string; hideSteps: boolean; disabled: boolean; loading: boolean; isPartner: boolean; loadingError: boolean; formState: { checked: "hstCanada" \| "otherRegion" \| "notRegistered"; errors: {}; }; }; refs?: { formRef: Ref; }; data?: { esRegions: { regionCode: string; taxType: string; displayName: string; }[]; countries: { countryCode: string; displayName: any; }[]; allCountries: { countryCode: string; displayName: any; }[]; provinces: { regionCode: string; taxType: string; displayName: string; }[]; }; slotProps?: { formState: { selectedRegion?: string; subRegion?: string; subRegionTaxNumber?: string; qstNumber?: string; province?: string; indirectTaxNumber?: string; hasQst?: boolean; hasSubRegionTaxNumber?: boolean; errors?: any; error?: string; }; }; }` | `undefined` | -| `fieldRequiredError` | `field-required-error` | Displayed under a field that is missing required information. | `string` | `"{fieldName} is required"` | -| `formStep` | `form-step` | | `string` | `"Step {step} of {count}"` | -| `generalErrorDescription` | `general-error-description` | Part of the alert displayed at the top of the page. | `string` | `"Please review your information and try again. If this problem continues, contact our {supportLink}."` | -| `generalErrorTitle` | `general-error-title` | Part of the alert displayed at the top of the page. | `string` | `"There was a problem submitting your information"` | -| `indirectTax` | `indirect-tax` | | `string` | `"Indirect Tax"` | -| `indirectTaxDescription` | `indirect-tax-description` | Displayed under the title of this step. | `string` | `"Indirect taxes (e.g. VAT, HST, GST) are transaction based taxes often applied to goods and services. Service providers are typically required to register with their tax authority and collect these taxes on behalf governments."` | -| `indirectTaxDetails` | `indirect-tax-details` | Displayed with indirect tax registration options. | `string` | `"Indirect tax details"` | -| `indirectTaxNumber` | `indirect-tax-number` | | `string` | `"{taxType, select, GST {GST number} HST {HST number} VAT {VAT number} CT {CT number} SST {SST number} GENERAL {Indirect tax number}}"` | -| `indirectTaxNumberError` | `indirect-tax-number-error` | | `string` | `"{taxType, select, GST {GST number} HST {HST number} VAT {VAT number} CT {CT number} SST {SST number} GENERAL {Indirect tax number}} is required"` | -| `isPartnerAlertDescription` | `is-partner-alert-description` | Part of the alert displayed at the top of the page if the participant is already a registered partner on impact.com. | `string` | `"If you don’t recognize this referral program provider or believe this is a mistake, please contact our {supportLink} or sign up for this referral program with a different email."` | -| `isPartnerAlertHeader` | `is-partner-alert-header` | Part of the alert displayed at the top of the page if the participant is already a registered partner on impact.com. | `string` | `"An account with this email already exists with our referral program provider, impact.com"` | -| `isRegisteredQST` | `is-registered-q-s-t` | Displayed to participants registered for indirect tax in Quebec, Canada. | `string` | `"I am registered for QST Tax"` | -| `isRegisteredSubRegionIncomeTax` | `is-registered-sub-region-income-tax` | Displayed to participants registered for indirect tax in Spain. | `string` | `"I am an individual registered for Income Tax purposes in Spain, and withholding tax will apply to any payments made to me."` | -| `loadingErrorAlertDescription` | `loading-error-alert-description` | Part of the alert displayed at the top of the page. | `string` | `"Please refresh the page and try again. If this problem continues, contact our {supportLink}."` | -| `loadingErrorAlertHeader` | `loading-error-alert-header` | Part of the alert displayed at the top of the page. | `string` | `"There was a problem loading your form"` | -| `notRegistered` | `not-registered` | | `string` | `"Not registered for indirect tax"` | -| `notRegisteredSubtext` | `not-registered-subtext` | Participants based in the US are considered not registered. | `string` | `"If you’ve never set up indirect tax with your tax authority, then you’re likely not considered registered."` | -| `otherRegion` | `other-region` | | `string` | `"Registered for indirect tax"` | -| `otherRegionSubtext` | `other-region-subtext` | Selecting this option will display fields to enter indirect tax details. | `string` | `"If you’ve registered with your tax authority, add your information to stay tax compliant."` | -| `province` | `province` | | `string` | `"Province"` | -| `qstNumber` | `qst-number` | Displayed to participants registered for QST. | `string` | `"QST number"` | -| `searchForCountryText` | `search-for-country-text` | Placeholder text displayed in the country search dropdown | `string` | `"Search for country.."` | -| `selectedRegion` | `selected-region` | | `string` | `"Country / region of indirect tax"` | -| `subRegion` | `sub-region` | Displayed to participants registered in Spain. | `string` | `"Sub-region"` | -| `subRegionTaxNumberLabel` | `sub-region-tax-number-label` | | `string` | `"Income tax number"` | -| `supportLink` | `support-link` | | `string` | `"support team"` | -| `taxAndPayoutsDescription` | `tax-and-payouts-description` | Displayed at the top of the page on all set up steps. | `string` | `"Submit your tax documents and add your banking information to receive your rewards."` | +| Property | Attribute | Description | Type | Default | +| -------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backButton` | `back-button` | | `string` | `"Back"` | +| `cannotChangeInfoAlert` | `cannot-change-info-alert` | Communicate that after this step, only Support can change personal and indirect tax information. | `string` | `"Changes to your personal and indirect tax information can only be made through our {supportLink} after you complete this step. Make sure these are correct before continuing."` | +| `continueButton` | `continue-button` | | `string` | `"Continue"` | +| `demoData` | -- | | `{ states?: { step: string; hideSteps: boolean; disabled: boolean; loading: boolean; isPartner: boolean; isPartnerLegacy: boolean; loadingError: boolean; formState: { checked: "hstCanada" \| "otherRegion" \| "notRegistered"; errors: {}; }; }; refs?: { formRef: Ref; }; data?: { esRegions: { regionCode: string; taxType: string; displayName: string; }[]; countries: { countryCode: string; displayName: any; }[]; allCountries: { countryCode: string; displayName: any; }[]; provinces: { regionCode: string; taxType: string; displayName: string; }[]; }; slotProps?: { formState: { selectedRegion?: string; subRegion?: string; subRegionTaxNumber?: string; qstNumber?: string; province?: string; indirectTaxNumber?: string; hasQst?: boolean; hasSubRegionTaxNumber?: boolean; errors?: any; error?: string; }; }; }` | `undefined` | +| `fieldRequiredError` | `field-required-error` | Displayed under a field that is missing required information. | `string` | `"{fieldName} is required"` | +| `formStep` | `form-step` | | `string` | `"Step {step} of {count}"` | +| `generalErrorDescription` | `general-error-description` | Part of the alert displayed at the top of the page. | `string` | `"Please review your information and try again. If this problem continues, contact our {supportLink}."` | +| `generalErrorTitle` | `general-error-title` | Part of the alert displayed at the top of the page. | `string` | `"There was a problem submitting your information"` | +| `indirectTax` | `indirect-tax` | | `string` | `"Indirect Tax"` | +| `indirectTaxDescription` | `indirect-tax-description` | Displayed under the title of this step. | `string` | `"Indirect taxes (e.g. VAT, HST, GST) are transaction based taxes often applied to goods and services. Service providers are typically required to register with their tax authority and collect these taxes on behalf governments."` | +| `indirectTaxDetails` | `indirect-tax-details` | Displayed with indirect tax registration options. | `string` | `"Indirect tax details"` | +| `indirectTaxNumber` | `indirect-tax-number` | | `string` | `"{taxType, select, GST {GST number} HST {HST number} VAT {VAT number} CT {CT number} SST {SST number} GENERAL {Indirect tax number}}"` | +| `indirectTaxNumberError` | `indirect-tax-number-error` | | `string` | `"{taxType, select, GST {GST number} HST {HST number} VAT {VAT number} CT {CT number} SST {SST number} GENERAL {Indirect tax number}} is required"` | +| `isPartnerAlertDescription` | `is-partner-alert-description` | Part of the alert displayed at the top of the page if the participant is already a registered partner on impact.com. | `string` | `"If you don’t recognize this referral program provider or believe this is a mistake, please contact our {supportLink} or sign up for this referral program with a different email."` | +| `isPartnerAlertHeader` | `is-partner-alert-header` | Part of the alert displayed at the top of the page if the participant is already a registered partner on impact.com. | `string` | `"An account with this email already exists with our referral program provider, impact.com"` | +| `isRegisteredQST` | `is-registered-q-s-t` | Displayed to participants registered for indirect tax in Quebec, Canada. | `string` | `"I am registered for QST Tax"` | +| `isRegisteredSubRegionIncomeTax` | `is-registered-sub-region-income-tax` | Displayed to participants registered for indirect tax in Spain. | `string` | `"I am an individual registered for Income Tax purposes in Spain, and withholding tax will apply to any payments made to me."` | +| `loadingErrorAlertDescription` | `loading-error-alert-description` | Part of the alert displayed at the top of the page. | `string` | `"Please refresh the page and try again. If this problem continues, contact our {supportLink}."` | +| `loadingErrorAlertHeader` | `loading-error-alert-header` | Part of the alert displayed at the top of the page. | `string` | `"There was a problem loading your form"` | +| `notRegistered` | `not-registered` | | `string` | `"Not registered for indirect tax"` | +| `notRegisteredSubtext` | `not-registered-subtext` | Participants based in the US are considered not registered. | `string` | `"If you’ve never set up indirect tax with your tax authority, then you’re likely not considered registered."` | +| `otherRegion` | `other-region` | | `string` | `"Registered for indirect tax"` | +| `otherRegionSubtext` | `other-region-subtext` | Selecting this option will display fields to enter indirect tax details. | `string` | `"If you’ve registered with your tax authority, add your information to stay tax compliant."` | +| `province` | `province` | | `string` | `"Province"` | +| `qstNumber` | `qst-number` | Displayed to participants registered for QST. | `string` | `"QST number"` | +| `searchForCountryText` | `search-for-country-text` | Placeholder text displayed in the country search dropdown | `string` | `"Search for country.."` | +| `selectedRegion` | `selected-region` | | `string` | `"Country / region of indirect tax"` | +| `subRegion` | `sub-region` | Displayed to participants registered in Spain. | `string` | `"Sub-region"` | +| `subRegionTaxNumberLabel` | `sub-region-tax-number-label` | | `string` | `"Income tax number"` | +| `supportLink` | `support-link` | | `string` | `"support team"` | +| `taxAndPayoutsDescription` | `tax-and-payouts-description` | Displayed at the top of the page on all set up steps. | `string` | `"Submit your tax documents and add your banking information to receive your rewards."` | ## Dependencies diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/sqm-indirect-tax-form-view.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/sqm-indirect-tax-form-view.tsx index bc270e47db..7c1986cec3 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/sqm-indirect-tax-form-view.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/sqm-indirect-tax-form-view.tsx @@ -9,6 +9,7 @@ export interface IndirectTaxFormViewProps { loading: boolean; disabled: boolean; isPartner: boolean; + isPartnerLegacy: boolean; hideSteps: boolean; loadingError: boolean; formState: { @@ -288,7 +289,7 @@ export const IndirectTaxFormView = (props: IndirectTaxFormViewProps) => { )} )} - {states.isPartner && ( + {states.isPartnerLegacy && ( { id="notRegistered" checked={formState.checked === "notRegistered"} onInput={() => callbacks.onChange("notRegistered")} - disabled={states.disabled || states.isPartner} + disabled={states.disabled} >
{text.notRegistered} @@ -349,7 +350,7 @@ export const IndirectTaxFormView = (props: IndirectTaxFormViewProps) => { id="otherRegion" checked={formState.checked === "otherRegion"} onInput={() => callbacks.onChange("otherRegion")} - disabled={states.disabled || states.isPartner} + disabled={states.disabled} >
{text.otherRegion} diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/sqm-indirect-tax-form.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/sqm-indirect-tax-form.tsx index 27785c6e00..6cae000849 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/sqm-indirect-tax-form.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-indirect-tax-form/sqm-indirect-tax-form.tsx @@ -254,7 +254,7 @@ export class IndirectTaxForm { (CONNECT_PARTNER); + const [completeImpactPartner, { loading: completeLoading }] = + useMutation(COMPLETE_PARTNER); const userForm = useParentValue(USER_FORM_CONTEXT_NAMESPACE); const { data: userData, @@ -220,6 +259,76 @@ export function useIndirectTaxForm(props: IndirectTaxForm) { setFormState((p) => ({ ...p, [field]: value })); }; + async function connectPartner(formData) { + const vars = { + user: { + id: user.id, + accountId: user.accountId, + }, + firstName: userForm.firstName, + lastName: userForm.lastName, + countryCode: userForm.countryCode, + currency: userForm.currency, + address: userForm.address, + city: userForm.city, + state: userForm.state, + postalCode: userForm.postalCode, + phoneNumber: userForm.phoneNumber, + phoneNumberCountryCode: userForm.phoneNumberCountryCode, + indirectTaxCountryCode: formData.selectedRegion, + indirectTaxRegion: formData.province || formData.subRegion, + indirectTaxId: formData.indirectTaxNumber, + additionalTaxId: formData.qstNumber, + withholdingTaxId: formData.subRegionTaxNumber, + } as ImpactConnectionInput; + + // If the partner has already been started call completeImpactConnection + // to fill in the remaining details. Otherwise create from scratch. + + let result = null; + let connectionResult; + if (userData?.user?.impactConnection?.connectionStatus === "STARTED") { + result = await completeImpactPartner({ + vars, + }); + connectionResult = (result as CompletePartnerResult) + ?.completeImpactConnection; + } else { + result = await connectImpactPartner({ + vars, + }); + connectionResult = (result as ConnectPartnerResult) + ?.createImpactConnection; + } + + if (!result || (result as Error)?.message) throw new Error(); + if (!connectionResult?.success) { + // Output backend errors to console for now + console.error( + "Failed to create Impact connection: ", + connectionResult?.validationErrors + ); + + throw new Error(); + } + + await refetch(); + + const resultPublisher = connectionResult?.user?.impactConnection?.publisher; + + const hasValidCurrentDocument = + validTaxDocument(resultPublisher?.requiredTaxDocumentType) && + resultPublisher?.currentTaxDocument; + + // Fire form change event + window.dispatchEvent(new Event(TAX_FORM_UPDATED_EVENT_KEY)); + + return { + resultPublisher, + hasValidCurrentDocument, + }; + } + const onSubmit = async (event: any) => { if (!option) { setErrors({ taxDetails: true }); @@ -251,55 +360,8 @@ export function useIndirectTaxForm(props: IndirectTaxForm) { setLoading(true); try { - const vars = { - user: { - id: user.id, - accountId: user.accountId, - }, - firstName: userForm.firstName, - lastName: userForm.lastName, - countryCode: userForm.countryCode, - currency: userForm.currency, - address: userForm.address, - city: userForm.city, - state: userForm.state, - postalCode: userForm.postalCode, - phoneNumber: userForm.phoneNumber, - phoneNumberCountryCode: userForm.phoneNumberCountryCode, - indirectTaxCountryCode: formData.selectedRegion, - indirectTaxRegion: formData.province || formData.subRegion, - indirectTaxId: formData.indirectTaxNumber, - additionalTaxId: formData.qstNumber, - withholdingTaxId: formData.subRegionTaxNumber, - } as ImpactConnectionInput; - - const result = await connectImpactPartner({ - vars, - }); - - if (!result || (result as Error)?.message) throw new Error(); - if (!(result as ConnectPartnerResult).createImpactConnection?.success) { - // Output backend errors to console for now - console.error( - "Failed to create Impact connection: ", - (result as ConnectPartnerResult).createImpactConnection - .validationErrors - ); - - throw new Error(); - } - - await refetch(); - - const resultPublisher = (result as ConnectPartnerResult) - .createImpactConnection?.user?.impactConnection?.publisher; - - const hasValidCurrentDocument = - validTaxDocument(resultPublisher?.requiredTaxDocumentType) && - resultPublisher?.currentTaxDocument; - - // Fire form change event - window.dispatchEvent(new Event(TAX_FORM_UPDATED_EVENT_KEY)); + const { resultPublisher, hasValidCurrentDocument } = + await connectPartner(formData); if ( resultPublisher?.requiredTaxDocumentType && @@ -331,9 +393,13 @@ export function useIndirectTaxForm(props: IndirectTaxForm) { states: { step: step?.replace("/", ""), hideSteps: context.hideSteps, - disabled: loading || countriesLoading || connectLoading, - loading: loading || connectLoading || countriesLoading, + disabled: + loading || countriesLoading || connectLoading || completeLoading, + loading: loading || connectLoading || countriesLoading || completeLoading, isPartner: !!userData?.user?.impactConnection?.publisher, + isPartnerLegacy: + !!userData?.user?.impactConnection?.publisher && + userData?.user?.impactConnection?.connectionStatus !== "STARTED", loadingError: !!userError?.message, formState: { checked: option, diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/data.ts b/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/data.ts index 4df1c851c8..a242edc2f5 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/data.ts +++ b/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/data.ts @@ -47,6 +47,7 @@ export const GET_USER = gql` user: viewer { ... on User { id + accountId firstName lastName email @@ -68,6 +69,7 @@ export const GET_USER = gql` } impactConnection { connected + connectionStatus user { firstName lastName @@ -161,8 +163,14 @@ export type ImpactPublisher = { balance: string; }; }; +export type ImpactUser = { + firstName: string; + lastName: string; +}; export type UserQuery = { user: { + id?: string; + accountId?: string; firstName?: string; lastName?: string; email?: string; @@ -186,10 +194,8 @@ export type UserQuery = { } | null; impactConnection: null | { connected: boolean; - user: { - firstName: string; - lastName: string; - } | null; + connectionStatus: "NOT_STARTED" | "STARTED" | "COMPLETED"; + user: null | ImpactUser; publisher: null | ImpactPublisher; }; }; diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/useTaxAndCash.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/useTaxAndCash.tsx index 9b939497bf..0f74322c88 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/useTaxAndCash.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/useTaxAndCash.tsx @@ -2,12 +2,13 @@ import { getContextValueName, useHost, useLocale, + useMutation, useParentQuery, useParentState, useUserIdentity, } from "@saasquatch/component-boilerplate"; import { useEffect, useMemo } from "@saasquatch/universal-hooks"; -import { getCountryObj } from "../utils"; +import { getCountryObj, isValidI18nPhoneNumber } from "../utils"; import { COUNTRIES_NAMESPACE, COUNTRIES_QUERY_NAMESPACE, @@ -32,6 +33,10 @@ import { UserFormContext, UserQuery, } from "./data"; +import { + COMPLETE_PARTNER, + CompletePartnerResult, +} from "../sqm-indirect-tax-form/useIndirectTaxForm"; function getCurrentStep(user: UserQuery["user"]) { if (!user.impactConnection?.connected || !user.impactConnection?.publisher) { @@ -44,8 +49,31 @@ function getCurrentStep(user: UserQuery["user"]) { withdrawalSettings, brandedSignup, payoutsAccount, + billingAddress, + billingCity, + billingCountryCode, + billingPostalCode, + phoneNumber, + phoneNumberCountryCode, } = user.impactConnection.publisher; + const isCompleted = user.impactConnection.connectionStatus === "COMPLETED"; + + if (!isCompleted) { + const hasBillingInfo = + billingAddress && + billingCity && + billingCountryCode && + billingPostalCode && + phoneNumberCountryCode && + phoneNumber && + isValidI18nPhoneNumber(phoneNumberCountryCode, phoneNumber); + + if (!hasBillingInfo) { + return "/1"; + } + } + // If they do have a required document, look at current document if (requiredTaxDocumentType && !currentTaxDocument) { // Specific to custom CASH setting, @@ -68,6 +96,9 @@ export function useTaxAndCash() { const user = useUserIdentity(); const locale = useLocale(); + const [completeImpactPartner] = + useMutation(COMPLETE_PARTNER); + // State for current step of form const [step, setStep] = useParentState({ namespace: TAX_CONTEXT_NAMESPACE, @@ -114,7 +145,11 @@ export function useTaxAndCash() { initialValue: [], }); - const { data, errors } = useParentQuery({ + const { + data, + errors, + refetch: refetchUser, + } = useParentQuery({ namespace: USER_QUERY_NAMESPACE, query: GET_USER, skip: !user, @@ -133,7 +168,7 @@ export function useTaxAndCash() { skip: !user, }); - const { data: financeNetworkData, refetch } = + const { data: financeNetworkData } = useParentQuery({ namespace: FINANCE_NETWORK_SETTINGS_NAMESPACE, query: GET_FINANCE_NETWORK_SETTINGS, @@ -231,13 +266,92 @@ export function useTaxAndCash() { } if (!host || !user) return; + // Attempts to finish an early-created ("STARTED") Impact connection. + // Returns true only when the mutation reports success, so the caller + // can decide whether it's safe to route off the refreshed user data. + async function completeConnection(user: UserQuery["user"]) { + const publisher = user?.impactConnection?.publisher; + + const hasBillingInfo = + publisher?.billingAddress && + publisher?.billingCity && + publisher?.billingCountryCode && + publisher?.billingPostalCode && + publisher?.phoneNumberCountryCode && + publisher?.phoneNumber && + isValidI18nPhoneNumber( + publisher.phoneNumberCountryCode, + publisher.phoneNumber + ); + + if (!hasBillingInfo) return false; + + const vars = { + user: { + id: data?.user?.id, + accountId: data?.user?.accountId, + }, + firstName: data?.user?.firstName, + lastName: data?.user?.lastName, + countryCode: publisher.billingCountryCode, + currency: publisher.currency, + address: publisher.billingAddress, + city: publisher.billingCity, + state: publisher.billingState, + postalCode: publisher.billingPostalCode, + phoneNumber: publisher.phoneNumber, + phoneNumberCountryCode: publisher.phoneNumberCountryCode, + } as Partial; + + try { + const result = await completeImpactPartner({ vars }); + if (!result || (result as Error)?.message) return false; + + const connectionResult = (result as CompletePartnerResult) + ?.completeImpactConnection; + if (!connectionResult?.success) { + console.error( + "Failed to complete Impact connection: ", + connectionResult?.validationErrors + ); + return false; + } + return true; + } catch (e) { + console.error("Failed to complete Impact connection: ", e); + return false; + } + } + if (data) { const user = data?.user; if (!user || step !== "/loading") return; - const currentStep = getCurrentStep(user); - setStep(currentStep); + async function routeAfterMaybeCompleting() { + let currentUser = user; + + if ( + user?.impactConnection?.publisher && + user?.impactConnection.connectionStatus === "STARTED" + ) { + // Finish the early-created connection before routing so the next + // step sees the completed status. Only trust the refetched data + // if the completion mutation actually succeeded. + const completed = await completeConnection(user); + if (completed) { + const refreshed = await refetchUser(); + if (refreshed && !(refreshed as Error)?.message) { + currentUser = (refreshed as UserQuery)?.user || currentUser; + } + } + } + + const currentStep = getCurrentStep(currentUser); + setStep(currentStep); + } + + routeAfterMaybeCompleting(); } }, [host, user, data?.user?.email, errors]); diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/readme.md b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/readme.md index 84b9f1657b..ecf0abd1b8 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/readme.md +++ b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/readme.md @@ -7,41 +7,41 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ------------------------------ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `address` | `address` | | `string` | `"Address"` | -| `allowBankingCollection` | `allow-banking-collection` | Edit the property called terms and conditions text to change what's displayed for {termsAndConditionsLink}. | `string` | `"I have read the {termsAndConditionsLink} and allow impact.com to collect my tax and banking information"` | -| `city` | `city` | | `string` | `"City"` | -| `continueButton` | `continue-button` | | `string` | `"Continue"` | -| `country` | `country` | | `string` | `"Country"` | -| `currency` | `currency` | | `string` | `"Currency"` | -| `currencyHelpText` | `currency-help-text` | | `string` | `"Choose your preferred payout currency"` | -| `demoData` | -- | | `{ states?: { step: string; hideState: boolean; hideSteps: boolean; disabled: boolean; loadingError: boolean; loading: boolean; isPartner: boolean; isUser: boolean; formState: { errors: {}; firstName: string; lastName: string; email: string; phoneNumberCountryCode: string; phoneNumber: string; countryCode: string; currency: string; address: string; city: string; state: string; postalCode: string; }; }; refs?: { formRef: Ref; currencyRef: Ref; phoneCountryRef: Ref; }; data?: { currencies: { displayName: string; currencyCode: string; }[]; countries: TaxCountry[]; phoneCountries: TaxCountry[]; allCurrencies: { displayName: string; currencyCode: string; }[]; allCountries: TaxCountry[]; regionLabelEnum: "STATE" \| "PROVINCE" \| "REGION"; regions: { label: string; value: string; }[]; }; setStep?: (value: string) => void; onSubmit?: (event: any) => Promise; }` | `undefined` | -| `email` | `email` | | `string` | `"Email"` | -| `fieldInvalidError` | `field-invalid-error` | Displayed under a field when it has an invalid entry. | `string` | `"{fieldName} is invalid"` | -| `fieldRequiredError` | `field-required-error` | Displayed under a field that is missing required information. | `string` | `"{fieldName} is required"` | -| `firstName` | `first-name` | | `string` | `"First name"` | -| `formStep` | `form-step` | | `string` | `"Step {step} of {count}"` | -| `generalErrorDescription` | `general-error-description` | Part of the alert displayed at the top of the page. | `string` | `"Please review your information and try again. If this problem continues, contact our {supportLink}."` | -| `generalErrorTitle` | `general-error-title` | Part of the alert displayed at the top of the page. | `string` | `"There was a problem submitting your information"` | -| `invalidCharacterError` | `invalid-character-error` | Displayed under Address or City fields that includes invalid characters (non-ASCII). | `string` | `"{fieldName} includes characters that aren't supported."` | -| `isPartnerAlertDescription` | `is-partner-alert-description` | Part of the alert displayed at the top of the page if the participant is already a registered partner on impact.com. | `string` | `"If you don’t recognize this referral program provider or believe this is a mistake, please contact our {supportLink} or sign up for this referral program with a different email."` | -| `isPartnerAlertHeader` | `is-partner-alert-header` | Part of the alert displayed at the top of the page if the participant is already a registered partner on impact.com. | `string` | `"An account with this email already exists with our referral program provider, impact.com"` | -| `lastName` | `last-name` | | `string` | `"Last name"` | -| `loadingErrorAlertDescription` | `loading-error-alert-description` | Part of the alert displayed at the top of the page. | `string` | `"Please refresh the page and try again. If this problem continues, contact our {supportLink}."` | -| `loadingErrorAlertHeader` | `loading-error-alert-header` | Part of the alert displayed at the top of the page. | `string` | `"There was a problem loading your form"` | -| `personalInformation` | `personal-information` | | `string` | `"Personal Information"` | -| `phoneNumber` | `phone-number` | | `string` | `"Phone number"` | -| `postalCode` | `postal-code` | | `string` | `"Postal code"` | -| `province` | `province` | | `string` | `"Province"` | -| `region` | `region` | | `string` | `"Region"` | -| `searchForCountryText` | `search-for-country-text` | Placeholder text displayed in the country search dropdown | `string` | `"Search for country.."` | -| `searchForCurrencyText` | `search-for-currency-text` | Placeholder text displayed in the currency search dropdown | `string` | `"Search for currency.."` | -| `state` | `state` | | `string` | `"State"` | -| `supportLink` | `support-link` | | `string` | `"support team"` | -| `taxAndPayoutsDescription` | `tax-and-payouts-description` | Displayed at the top of the page on all set up steps. | `string` | `"Submit your tax documents and add your banking information to receive your rewards."` | -| `termsAndConditionsLabel` | `terms-and-conditions-label` | The link text that appears in the terms and conditions checkbox | `string` | `"terms and conditions"` | -| `termsAndConditionsLink` | `terms-and-conditions-link` | The link that appears in the terms and conditions checkbox | `string` | `"https://terms.advocate.impact.com/PayoutTermsAndConditions.html"` | +| Property | Attribute | Description | Type | Default | +| ------------------------------ | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `address` | `address` | | `string` | `"Address"` | +| `allowBankingCollection` | `allow-banking-collection` | Edit the property called terms and conditions text to change what's displayed for {termsAndConditionsLink}. | `string` | `"I have read the {termsAndConditionsLink} and allow impact.com to collect my tax and banking information"` | +| `city` | `city` | | `string` | `"City"` | +| `continueButton` | `continue-button` | | `string` | `"Continue"` | +| `country` | `country` | | `string` | `"Country"` | +| `currency` | `currency` | | `string` | `"Currency"` | +| `currencyHelpText` | `currency-help-text` | | `string` | `"Choose your preferred payout currency"` | +| `demoData` | -- | | `{ states?: { step: string; hideState: boolean; hideSteps: boolean; disabled: boolean; loadingError: boolean; loading: boolean; isPartner: boolean; isUser: boolean; isPartnerLegacy: boolean; isUserLegacy: boolean; formState: { errors: {}; firstName: string; lastName: string; email: string; phoneNumberCountryCode: string; phoneNumber: string; countryCode: string; currency: string; address: string; city: string; state: string; postalCode: string; }; }; refs?: { formRef: Ref; currencyRef: Ref; phoneCountryRef: Ref; }; data?: { currencies: { displayName: string; currencyCode: string; }[]; countries: TaxCountry[]; phoneCountries: TaxCountry[]; allCurrencies: { displayName: string; currencyCode: string; }[]; allCountries: TaxCountry[]; regionLabelEnum: "STATE" \| "PROVINCE" \| "REGION"; regions: { label: string; value: string; }[]; partnerData: ImpactPublisher; userData: ImpactUser; }; setStep?: (value: string) => void; onSubmit?: (event: any) => Promise; }` | `undefined` | +| `email` | `email` | | `string` | `"Email"` | +| `fieldInvalidError` | `field-invalid-error` | Displayed under a field when it has an invalid entry. | `string` | `"{fieldName} is invalid"` | +| `fieldRequiredError` | `field-required-error` | Displayed under a field that is missing required information. | `string` | `"{fieldName} is required"` | +| `firstName` | `first-name` | | `string` | `"First name"` | +| `formStep` | `form-step` | | `string` | `"Step {step} of {count}"` | +| `generalErrorDescription` | `general-error-description` | Part of the alert displayed at the top of the page. | `string` | `"Please review your information and try again. If this problem continues, contact our {supportLink}."` | +| `generalErrorTitle` | `general-error-title` | Part of the alert displayed at the top of the page. | `string` | `"There was a problem submitting your information"` | +| `invalidCharacterError` | `invalid-character-error` | Displayed under Address or City fields that includes invalid characters (non-ASCII). | `string` | `"{fieldName} includes characters that aren't supported."` | +| `isPartnerAlertDescription` | `is-partner-alert-description` | Part of the alert displayed at the top of the page if the participant is already a registered partner on impact.com. | `string` | `"If you don’t recognize this referral program provider or believe this is a mistake, please contact our {supportLink} or sign up for this referral program with a different email."` | +| `isPartnerAlertHeader` | `is-partner-alert-header` | Part of the alert displayed at the top of the page if the participant is already a registered partner on impact.com. | `string` | `"An account with this email already exists with our referral program provider, impact.com"` | +| `lastName` | `last-name` | | `string` | `"Last name"` | +| `loadingErrorAlertDescription` | `loading-error-alert-description` | Part of the alert displayed at the top of the page. | `string` | `"Please refresh the page and try again. If this problem continues, contact our {supportLink}."` | +| `loadingErrorAlertHeader` | `loading-error-alert-header` | Part of the alert displayed at the top of the page. | `string` | `"There was a problem loading your form"` | +| `personalInformation` | `personal-information` | | `string` | `"Personal Information"` | +| `phoneNumber` | `phone-number` | | `string` | `"Phone number"` | +| `postalCode` | `postal-code` | | `string` | `"Postal code"` | +| `province` | `province` | | `string` | `"Province"` | +| `region` | `region` | | `string` | `"Region"` | +| `searchForCountryText` | `search-for-country-text` | Placeholder text displayed in the country search dropdown | `string` | `"Search for country.."` | +| `searchForCurrencyText` | `search-for-currency-text` | Placeholder text displayed in the currency search dropdown | `string` | `"Search for currency.."` | +| `state` | `state` | | `string` | `"State"` | +| `supportLink` | `support-link` | | `string` | `"support team"` | +| `taxAndPayoutsDescription` | `tax-and-payouts-description` | Displayed at the top of the page on all set up steps. | `string` | `"Submit your tax documents and add your banking information to receive your rewards."` | +| `termsAndConditionsLabel` | `terms-and-conditions-label` | The link text that appears in the terms and conditions checkbox | `string` | `"terms and conditions"` | +| `termsAndConditionsLink` | `terms-and-conditions-link` | The link that appears in the terms and conditions checkbox | `string` | `"https://terms.advocate.impact.com/PayoutTermsAndConditions.html"` | ## Dependencies diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/small-views/IndirectTaxDetailsView.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/small-views/IndirectTaxDetailsView.tsx index aa5ccdb588..8c687236d4 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/small-views/IndirectTaxDetailsView.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/small-views/IndirectTaxDetailsView.tsx @@ -220,7 +220,7 @@ export const OtherRegionSlotView = (props: IndirectDetailsSlotViewProps) => { exportparts="label: input-label, base: input-base" checked={formState.hasSubRegionTaxNumber} onSl-change={callbacks.onSpainToggle} - disabled={states.disabled || states.isPartner} + disabled={states.disabled} > {text.isRegisteredSubRegionIncomeTax} @@ -299,7 +299,7 @@ export const OtherRegionSlotView = (props: IndirectDetailsSlotViewProps) => { exportparts="label: input-label, base: input-base" onSl-change={callbacks.onQstToggle} checked={formState.hasQst} - disabled={states.isPartner || states.disabled} + disabled={states.disabled} > {text.isRegisteredQST} diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/sqm-user-info-form-view.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/sqm-user-info-form-view.tsx index ddd15fb31e..3cb5943b83 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/sqm-user-info-form-view.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/sqm-user-info-form-view.tsx @@ -1,9 +1,14 @@ import { h } from "@stencil/core"; +import { AsYouType, CountryCode } from "libphonenumber-js"; import { intl } from "../../../global/global"; import { createStyleSheet } from "../../../styling/JSS"; import { GeneralLoadingView } from "../TaxForm.stories"; -import { FORM_STEPS } from "../sqm-tax-and-cash/data"; -import { formatErrorMessage, validateBillingField } from "../utils"; +import { FORM_STEPS, ImpactPublisher, ImpactUser } from "../sqm-tax-and-cash/data"; +import { + formatErrorMessage, + isValidI18nPhoneNumber, + validateBillingField, +} from "../utils"; import { PHONE_EXTENSIONS } from "../phoneExtensions"; export interface UserInfoFormViewProps { @@ -13,6 +18,8 @@ export interface UserInfoFormViewProps { disabled: boolean; isPartner: boolean; isUser: boolean; + isPartnerLegacy: boolean; + isUserLegacy: boolean; hideSteps: boolean; hideState: boolean; loadingError?: boolean; @@ -69,6 +76,8 @@ export interface UserInfoFormViewProps { currencyCode: string; displayName: string; }[]; + partnerData?: ImpactPublisher; + userData?: ImpactUser; }; callbacks: { setCurrencySearch: (c: any) => void; @@ -266,19 +275,7 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { } = props; const { classes } = sheet; - const bankingCollectionText = intl.formatMessage( - { - id: "bankingCollectionText", - defaultMessage: text.allowBankingCollection, - }, - { - termsAndConditionsLink: ( - - {text.termsAndConditionsLabel} - - ), - } - ); + // T&C / banking-collection checkbox lives on sqm-partner-info-modal now. let regionLabel = undefined; switch (data.regionLabelEnum) { @@ -295,6 +292,32 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { regionLabel = text.state; } + // when creating an impact connection without sending phoneNumber data, the impactAPI defaults the value to "0000000" and the phoneNumberCountryCode to "DZ" + function isDisabledPartnerInput(field: string) { + if ( + data.partnerData?.phoneNumber === "0000000" && + (field === "phoneNumber" || field === "phoneNumberCountryCode") + ) { + return false; + } + // if bad phone number was previously saved, unlock the field so user can correct it + if ( + (field === "phoneNumber" || field === "phoneNumberCountryCode") && + states.isPartner && + !isValidI18nPhoneNumber( + data.partnerData?.phoneNumberCountryCode, + data.partnerData?.phoneNumber + ) + ) { + return false; + } + return states.isPartner && !!data.partnerData?.[field]; + } + + function isDisabledUserInput(field: string) { + return states.isUser && !!data.userData?.[field]; + } + return ( { )} )} - {(states.isPartner || states.isUser) && ( + {(states.isPartnerLegacy || states.isUserLegacy) && ( { exportparts="label: input-label, base: input-base" value={formState.firstName} label={text.firstName} - disabled={states.disabled || states.isUser || states.isPartner} + disabled={states.disabled || isDisabledUserInput("firstName")} {...(formState.errors?.firstName ? { class: classes.ErrorInput, @@ -441,7 +464,7 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { exportparts="label: input-label, base: input-base" value={formState.lastName} label={text.lastName} - disabled={states.disabled || states.isUser || states.isPartner} + disabled={states.disabled || isDisabledUserInput("lastName")} {...(formState.errors?.lastName ? { class: classes.ErrorInput, @@ -471,7 +494,9 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { name="/countryCode" label={text.country} value={formState.countryCode} - disabled={states.disabled || states.isPartner} + disabled={ + states.disabled || isDisabledPartnerInput("countryCode") + } {...(formState.errors?.countryCode ? { class: classes.ErrorInput, @@ -517,7 +542,10 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { exportparts="label: input-label, base: input-base" name="/phoneNumberCountryCode" value={formState.phoneNumberCountryCode} - disabled={states.disabled || states.isPartner} + disabled={ + states.disabled || + isDisabledPartnerInput("phoneNumberCountryCode") + } ref={(el: HTMLFormElement) => (refs.phoneCountryRef.current = el) } @@ -567,16 +595,36 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { exportparts="label: input-label, base: input-base" id="phoneNumber" name="/phoneNumber" - value={formState.phoneNumber} - validationError={({ value }) => { - // Naive phone number validation - validateBillingField(/[a-zA-Z]+/, value) && - formatErrorMessage( - text.phoneNumber, - text.error.fieldInvalidError - ); + value={ + formState.phoneNumber !== null + ? formState.phoneNumber + : "" + } + onSl-input={(e) => { + const target = e.target as HTMLInputElement; + const country = ((refs.phoneCountryRef.current + ?.value as string) || "US") as CountryCode; + target.value = new AsYouType(country).input( + target.value + ); }} - disabled={states.disabled || states.isPartner} + validationError={({ control, value, formData }) => { + // skip validation for values the user can't edit + if (control?.disabled) return undefined; + if (!value?.trim()) return undefined; + return isValidI18nPhoneNumber( + formData.phoneNumberCountryCode, + value + ) + ? undefined + : formatErrorMessage( + text.phoneNumber, + text.error.fieldInvalidError + ); + }} + disabled={ + states.disabled || isDisabledPartnerInput("phoneNumber") + } {...(formState.errors?.phoneNumber ? { class: classes.ErrorInput, @@ -604,7 +652,9 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { text.error.invalidCharacterError ) } - disabled={states.disabled || states.isPartner} + disabled={ + states.disabled || isDisabledPartnerInput("billingAddress") + } {...(formState.errors?.address ? { class: classes.ErrorInput, @@ -630,7 +680,9 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { text.error.invalidCharacterError ) } - disabled={states.disabled || states.isPartner} + disabled={ + states.disabled || isDisabledPartnerInput("billingCity") + } {...(formState.errors?.city ? { class: classes.ErrorInput, @@ -649,7 +701,9 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { id="state" name="/state" value={formState.state} - disabled={states.disabled || states.isPartner} + disabled={ + states.disabled || isDisabledPartnerInput("billingState") + } {...(formState.errors?.state ? { class: classes.ErrorInput, @@ -672,7 +726,10 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { id="postalCode" name="/postalCode" value={formState.postalCode} - disabled={states.disabled || states.isPartner} + disabled={ + states.disabled || + isDisabledPartnerInput("billingPostalCode") + } {...(formState.errors?.postalCode ? { class: classes.ErrorInput, @@ -691,7 +748,7 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { label={text.currency} menu value={formState.currency} - disabled={states.disabled || states.isPartner} + disabled={states.disabled || isDisabledPartnerInput("currency")} helpText={text.currencyHelpText} {...(formState.errors?.currency ? { @@ -730,35 +787,10 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { ))} - -
- { - e.target.value = e.target.checked; - }} - disabled={states.isPartner ? false : states.disabled} - required - value={formState.allowBankingCollection} - id="allowBankingCollection" - name="/allowBankingCollection" - > - {bankingCollectionText} - - {formState.errors?.allowBankingCollection && ( -

- {formatErrorMessage( - text.termsAndConditionsLabel, - formState.errors.allowBankingCollection - )} -

- )} -
diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/sqm-user-info-form.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/sqm-user-info-form.tsx index 95358c2e1e..785bd18063 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/sqm-user-info-form.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/sqm-user-info-form.tsx @@ -265,6 +265,8 @@ function useDemoUserInfoForm(props: TaxForm): UseUserInfoFormResult { loading: false, isPartner: false, isUser: false, + isPartnerLegacy: false, + isUserLegacy: false, loadingError: false, formState: { errors: { diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/useUserInfoForm.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/useUserInfoForm.tsx index 98ca925cb5..1b52b72301 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/useUserInfoForm.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/useUserInfoForm.tsx @@ -18,14 +18,34 @@ import { UserFormContext, UserQuery, } from "../sqm-tax-and-cash/data"; -import { objectIsFull } from "../utils"; +import { objectIsFull, toDomesticNumber, validTaxDocument } from "../utils"; import { TaxForm } from "./sqm-user-info-form"; import { + useMutation, useParent, useParentQueryValue, useParentValue, + useQuery, + useUserIdentity, } from "@saasquatch/component-boilerplate"; import { AddressRegions, ADDRESS_REGIONS } from "../subregions"; +import { TAX_FORM_UPDATED_EVENT_KEY } from "../eventKeys"; +import { + CompletePartnerResult, + ConnectPartnerResult, + COMPLETE_PARTNER, + CONNECT_PARTNER, +} from "../sqm-indirect-tax-form/useIndirectTaxForm"; +import { gql } from "graphql-request"; + +const GET_INDIRECT_TAX_COUNTRY_CODE = gql` + query getIndirectTaxCountryCode { + tenantSettings { + impactBrandCountryCode + impactBrandIndirectTaxCountryCode + } + } +`; // returns either error message if invalid or undefined if valid export type ValidationErrorFunction = (input: { @@ -68,9 +88,20 @@ export function useUserInfoForm(props: TaxForm) { USER_FORM_CONTEXT_NAMESPACE ); + const user = useUserIdentity(); + + const [connectImpactPartner, { loading: connectLoading }] = + useMutation(CONNECT_PARTNER); + + const [completeImpactPartner, { loading: completeLoading }] = + useMutation(COMPLETE_PARTNER); + + const { data: tenantData } = useQuery(GET_INDIRECT_TAX_COUNTRY_CODE, {}); + const { data, loading, + refetch, errors: userError, } = useParentQueryValue(USER_QUERY_NAMESPACE); @@ -120,9 +151,15 @@ export function useUserInfoForm(props: TaxForm) { lastName: user.impactConnection.user.lastName, countryCode: user.impactConnection.publisher.countryCode, currency: user.impactConnection.publisher.currency, + // when creating an impact connection without sending phoneNumber data, the impactAPI defaults the value to "0000000" and the phoneNumberCountryCode to "DZ" phoneNumberCountryCode: - user.impactConnection.publisher.phoneNumberCountryCode, - phoneNumber: user.impactConnection.publisher.phoneNumber, + user.impactConnection.publisher.phoneNumber === "0000000" + ? null + : user.impactConnection.publisher.phoneNumberCountryCode, + phoneNumber: + user.impactConnection.publisher.phoneNumber === "0000000" + ? null + : user.impactConnection.publisher.phoneNumber, address: user.impactConnection.publisher.billingAddress, city: user.impactConnection.publisher.billingCity, state: user.impactConnection.publisher.billingState, @@ -132,8 +169,8 @@ export function useUserInfoForm(props: TaxForm) { // Initialise with user information setUserFormContext({ email, - firstName: user.firstName, - lastName: user.lastName, + firstName: user.impactConnection?.user?.firstName || user.firstName, + lastName: user.impactConnection?.user?.lastName || user.lastName, countryCode: user.countryCode || "US", currency: user.customFields?.currency, phoneNumberCountryCode: @@ -200,6 +237,74 @@ export function useUserInfoForm(props: TaxForm) { } }, [currencySearch, currencies]); + async function connectPartner(formData) { + const vars = { + user: { + id: user.id, + accountId: user.accountId, + }, + firstName: formData.firstName, + lastName: formData.lastName, + countryCode: formData.countryCode, + currency: formData.currency, + address: formData.address, + city: formData.city, + state: formData.state, + postalCode: formData.postalCode, + phoneNumber: toDomesticNumber( + formData.phoneNumberCountryCode, + formData.phoneNumber + ), + phoneNumberCountryCode: formData.phoneNumberCountryCode, + } as Partial; + + // If the partner has already been started via early partner creation + // (connectionStatus === "STARTED"), complete it. Otherwise create from scratch. + const userData = data?.user; + let result = null; + let connectionResult; + if (userData?.impactConnection?.connectionStatus === "STARTED") { + result = await completeImpactPartner({ + vars, + }); + connectionResult = (result as CompletePartnerResult) + ?.completeImpactConnection; + } else { + result = await connectImpactPartner({ + vars, + }); + connectionResult = (result as ConnectPartnerResult) + ?.createImpactConnection; + } + + if (!result || (result as Error)?.message) throw new Error(); + if (!connectionResult?.success) { + // Output backend errors to console for now + console.error( + "Failed to create Impact connection: ", + connectionResult?.validationErrors + ); + + throw new Error(); + } + + await refetch(); + + const resultPublisher = connectionResult?.user?.impactConnection?.publisher; + + const hasValidCurrentDocument = + validTaxDocument(resultPublisher?.requiredTaxDocumentType) && + resultPublisher?.currentTaxDocument; + + // Fire form change event + window.dispatchEvent(new Event(TAX_FORM_UPDATED_EVENT_KEY)); + + return { + resultPublisher, + hasValidCurrentDocument, + }; + } + async function onSubmit(event: any) { let formControls = event.target.getFormControls(); @@ -236,12 +341,17 @@ export function useUserInfoForm(props: TaxForm) { const { allowBankingCollection, ...userData } = formData; + const normalizedPhoneNumber = toDomesticNumber( + userData.phoneNumberCountryCode, + userData.phoneNumber + ); + setUserFormContext({ ...userFormContext, firstName: userData.firstName, lastName: userData.lastName, phoneNumberCountryCode: userData.phoneNumberCountryCode, - phoneNumber: userData.phoneNumber, + phoneNumber: normalizedPhoneNumber, countryCode: userData.countryCode, address: userData.address, city: userData.city, @@ -250,10 +360,55 @@ export function useUserInfoForm(props: TaxForm) { currency: userData.currency, }); - const nextStep = context.overrideNextStep || "/2"; + const skipNextStep = getSkipNextStep(userData); + + if (skipNextStep) { + try { + const { resultPublisher, hasValidCurrentDocument } = + await connectPartner({ + ...formData, + phoneNumber: normalizedPhoneNumber, + }); + + if ( + resultPublisher?.requiredTaxDocumentType && + !hasValidCurrentDocument + ) { + // Go to docusign form + setStep("/3"); + } else { + if (resultPublisher?.brandedSignup) { + // Go to banking information form + setStep("/4"); + } else { + // Go right to the dashboard + setStep("/dashboard"); + } + } + return; + } catch (e) { + setErrors({ general: true }); + return; + } + } + + const nextStep = context.overrideNextStep || (skipNextStep ? "/3" : "/2"); setStep(nextStep); } + const indirectTaxCountry = + tenantData?.tenantSettings?.impactBrandIndirectTaxCountryCode; + + const hasIndirectTax = !!indirectTaxCountry; + + function getSkipNextStep(userData) { + if (!hasIndirectTax) return true; + if (userData.countryCode === "US") return true; + if (hasIndirectTax && userData.countryCode !== indirectTaxCountry) + return true; + return false; + } + const hasStates = ["ES", "AU", "US", "CA"].includes( userFormContext.countryCode ); @@ -284,16 +439,25 @@ export function useUserInfoForm(props: TaxForm) { allCountries: countries, regionLabelEnum: regionObj?.labelEnum, regions: regionObj?.regions || [], + partnerData: data?.user?.impactConnection?.publisher, + userData: data?.user?.impactConnection?.user, }, states: { step: step?.replace("/", ""), hideState: !hasStates, hideSteps: !!context.hideSteps, - disabled: loading, + disabled: loading || connectLoading || completeLoading, loadingError: !!userError?.message, - loading: loading, + loading: loading || connectLoading || completeLoading, isPartner: !!data?.user?.impactConnection?.publisher, isUser: !!data?.user?.impactConnection?.user, + // Show banner when pre-existing partner/user was created with legacy mutation createImpactConnection + isPartnerLegacy: + !!data?.user?.impactConnection?.publisher && + data?.user?.impactConnection?.connectionStatus !== "STARTED", + isUserLegacy: + !!data?.user?.impactConnection?.user && + data?.user?.impactConnection?.connectionStatus !== "STARTED", formState: { ...userFormContext, errors: formErrors, diff --git a/packages/mint-components/src/components/tax-and-cash/utils.ts b/packages/mint-components/src/components/tax-and-cash/utils.ts index 59635296e8..ee25ff69a5 100644 --- a/packages/mint-components/src/components/tax-and-cash/utils.ts +++ b/packages/mint-components/src/components/tax-and-cash/utils.ts @@ -1,6 +1,76 @@ +import { CountryCode, parsePhoneNumberFromString } from "libphonenumber-js"; import { intl } from "../../global/global"; import { TaxDocumentType } from "./sqm-tax-and-cash/data"; +/** + * Normalize user input to the domestic form Impact stores: digits only, + * with the country dial code and trunk-zero prefix removed. + */ +export function toDomesticNumber( + phoneCountryCode: string | undefined, + input: string | undefined, +): string { + if (!input) return ""; + const parsed = parsePhoneNumberFromString( + input, + phoneCountryCode?.toUpperCase() as CountryCode, + ); + return (parsed?.nationalNumber as string) ?? input.replace(/\D/g, ""); +} + +/** + * Validates a phone number against both libphonenumber-js's per-country + * pattern rules AND Impact's I18nPhoneNumber length rules, applied to the + * sanitized value we'll submit. + */ +export function isValidI18nPhoneNumber( + phoneCountryCode: string | undefined, + phoneNumber: string | undefined, +): boolean { + if (!phoneCountryCode || !phoneNumber?.trim()) return false; + const country = phoneCountryCode.toUpperCase() as CountryCode; + const parsed = parsePhoneNumberFromString(phoneNumber, country); + if (!parsed?.isValid()) return false; + return passesImpactBackendLengthCheck( + country, + toDomesticNumber(country, phoneNumber), + ); +} + +/** + * Mirrors estalea.bucket.phone.I18nPhoneNumber.isValidI18nPhoneNumber() + * length rules on the already-sanitized (digits-only) submission value. + */ +function passesImpactBackendLengthCheck( + country: string, + digits: string, +): boolean { + if (!digits) return false; + switch (country) { + case "US": + case "CA": { + const n = digits.startsWith("1") ? digits.slice(1) : digits; + return n.length === 10; + } + case "AU": + return digits.length === 9 || digits.length === 10; + case "NZ": { + let n = digits; + if (n.length > 9) { + if (n.startsWith("640")) n = n.slice(3); + else if (n.startsWith("0")) n = n.slice(1); + } + return n.length >= 8 && n.length <= 10; + } + default: + return stripLeadingZero(digits).length >= 6; + } +} + +function stripLeadingZero(digits: string): string { + return digits.startsWith("0") ? digits.slice(1) : digits; +} + export function validTaxDocument(requiredType: TaxDocumentType | undefined) { const validTypes = ["W9", "W8BENE", "W8BEN"]; if (validTypes.includes(requiredType)) return true; diff --git a/packages/mint-components/src/saasquatch.d.ts b/packages/mint-components/src/saasquatch.d.ts index 777fd82fb9..5dcd571bb9 100644 --- a/packages/mint-components/src/saasquatch.d.ts +++ b/packages/mint-components/src/saasquatch.d.ts @@ -53,6 +53,7 @@ interface CurrentTaxDocument { } interface ImpactConnection { connected: boolean; + connectionStatus?: "NOT_STARTED" | "STARTED" | "COMPLETED"; taxHandlingEnabled: boolean; publisher: null | { requiredTaxDocumentType: null | "W9" | "W8BEN" | "W8BENE"; From 7af7493a70865dc7a8a7cb1c79179bbfbf4d0a5c Mon Sep 17 00:00:00 2001 From: Sam Beveridge Date: Tue, 28 Jul 2026 10:35:09 -0700 Subject: [PATCH 2/3] ported over some bug fixes as well --- .../useEmailVerification.ts | 34 +++++++++++++------ .../sqm-user-info-form/useUserInfoForm.tsx | 3 +- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/mint-components/src/components/sqm-widget-verification/sqm-email-verification/useEmailVerification.ts b/packages/mint-components/src/components/sqm-widget-verification/sqm-email-verification/useEmailVerification.ts index 3a9e9bbbbf..a54b547463 100644 --- a/packages/mint-components/src/components/sqm-widget-verification/sqm-email-verification/useEmailVerification.ts +++ b/packages/mint-components/src/components/sqm-widget-verification/sqm-email-verification/useEmailVerification.ts @@ -87,8 +87,8 @@ export function useUpsertUserEmail() { export function useWidgetEmailVerification( props: WidgetEmailVerification ): WidgetEmailVerificationViewProps { - const [_, setShowCode] = useParent(SHOW_CODE_NAMESPACE); - const [email, setEmail] = useParent(VERIFICATION_EMAIL_NAMESPACE); + const [_, setShowCode] = useParent(SHOW_CODE_NAMESPACE); + const [_email, setEmail] = useParent(VERIFICATION_EMAIL_NAMESPACE); const [emailExists, setEmailExists] = useState(false); const [error, setError] = useState(false); @@ -121,27 +121,41 @@ export function useWidgetEmailVerification( if (!data?.viewer) return; setLoading(true); - const toAddress = data.viewer.email; + setMutationError(false); + setError(false); + + let toAddress = data.viewer.email; if (!toAddress) { // If no email on the user, set one - const formData = e.detail.formData; - const newEmail = formData.get("email").toString(); + const formData = e.detail?.formData; + const newEmail = formData?.get("email")?.toString() ?? ""; if (!emailRegex.test(newEmail)) { setError(true); + setLoading(false); return; } - const result = await upsertUserEmail(newEmail); - if (!result || !result.user.email) setError(true); + const upsertResult = await upsertUserEmail(newEmail); + if (!upsertResult || !upsertResult.upsertUser?.email) { + setError(true); + setLoading(false); + return; + } + // Read the address back from the mutation, not the pre-upsert snapshot + toAddress = upsertResult.upsertUser.email; } // UI should not allow this call til initialisation is done - if (!initialized) return; + if (!initialized) { + setLoading(false); + return; + } const result = await sendEmail(); - if (!result || !result.success) setMutationError(true); - else { + if (!result || !result.success) { + setMutationError(true); + } else { // This is used to let the code verification widget know an email was already sent setEmail(toAddress); setShowCode(true); diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/useUserInfoForm.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/useUserInfoForm.tsx index 1b52b72301..55f73956bf 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/useUserInfoForm.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-user-info-form/useUserInfoForm.tsx @@ -52,6 +52,7 @@ export type ValidationErrorFunction = (input: { control; key: string; value; + formData?: FormState; }) => string | undefined; export type FormState = { @@ -320,7 +321,7 @@ export function useUserInfoForm(props: TaxForm) { // custom validation if (typeof control.validationError === "function") { const validate = control.validationError as ValidationErrorFunction; - const validationError = validate({ control, key, value }); + const validationError = validate({ control, key, value, formData }); if (validationError) jsonpointer.set(errors, key, validationError); } From 754071f26f79569e9cbd810e778c33e736386d22 Mon Sep 17 00:00:00 2001 From: Andy Date: Tue, 28 Jul 2026 10:45:29 -0700 Subject: [PATCH 3/3] Update types to fix stencilbook and port over minor UI change in sqm-code-verification --- .../components/sqm-leaderboard-rank/useLeaderboardRank.tsx | 2 +- .../sqm-code-verification/sqm-code-verification-view.tsx | 6 +++--- .../components/tax-and-cash/sqm-tax-and-cash/ErrorView.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/mint-components/src/components/sqm-leaderboard-rank/useLeaderboardRank.tsx b/packages/mint-components/src/components/sqm-leaderboard-rank/useLeaderboardRank.tsx index b5982cb898..c7f4ab9af7 100644 --- a/packages/mint-components/src/components/sqm-leaderboard-rank/useLeaderboardRank.tsx +++ b/packages/mint-components/src/components/sqm-leaderboard-rank/useLeaderboardRank.tsx @@ -68,7 +68,7 @@ export function useLeaderboardRank( return { data: { - rank: fullRankText, + rank: fullRankText as string, }, }; } diff --git a/packages/mint-components/src/components/sqm-widget-verification/sqm-code-verification/sqm-code-verification-view.tsx b/packages/mint-components/src/components/sqm-widget-verification/sqm-code-verification/sqm-code-verification-view.tsx index 8bfba0154e..270b43689d 100644 --- a/packages/mint-components/src/components/sqm-widget-verification/sqm-code-verification/sqm-code-verification-view.tsx +++ b/packages/mint-components/src/components/sqm-widget-verification/sqm-code-verification/sqm-code-verification-view.tsx @@ -189,6 +189,9 @@ export function WidgetCodeVerificationView( {states.verifyFailed && (

{text.invalidCodeText}

)} +
+ {resendCodeText} +
-
- {resendCodeText} -
); diff --git a/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/ErrorView.tsx b/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/ErrorView.tsx index 26444e57cf..544798146b 100644 --- a/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/ErrorView.tsx +++ b/packages/mint-components/src/components/tax-and-cash/sqm-tax-and-cash/ErrorView.tsx @@ -1,9 +1,9 @@ -import { Host, h } from "@stencil/core"; +import { Host, VNode, h } from "@stencil/core"; import { createStyleSheet } from "../../../styling/JSS"; interface ErrorProps { loadingErrorAlertHeader: string; - loadingErrorAlertDescription: string; + loadingErrorAlertDescription: string | (string | VNode)[]; } const styles = {