Cash reward redemption#490
Conversation
…o cash-reward-redemption
There was a problem hiding this comment.
Pull request overview
This PR enhances the Tax & Cash banking info form by introducing structured, per-field validation error handling based on Impact API validation metadata, and adds a new “Partner Info Modal” component to the Mint component library/stencilbook.
Changes:
- Map backend validation errors (
field+errorPath) to form field names and frontend-friendly error codes. - Add configurable per-field ICU error-message templates and wire them into form validation rendering.
- Introduce a new
sqm-partner-info-modalcomponent (view + stories) and register it in the stencilbook; bump package version.
Reviewed changes
Copilot reviewed 9 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/mint-components/src/components/tax-and-cash/sqm-banking-info-form/useBankingInfoForm.tsx | Adds API→form field/error-code mapping and includes errorPath in GraphQL validationErrors. |
| packages/mint-components/src/components/tax-and-cash/sqm-banking-info-form/sqm-banking-info-form.tsx | Adds per-field error message props + ICU templates and renders richer invalid messages when an API errorCode is present. |
| packages/mint-components/src/components/tax-and-cash/sqm-banking-info-form/sqm-banking-info-form-view.tsx | Extends form error shape to include errorCode and adds text.errorMessages. |
| packages/mint-components/src/components/tax-and-cash/sqm-banking-info-form/formDefinitions.tsx | Passes errorCode + fieldName into validation message builder for each form input. |
| packages/mint-components/src/components/tax-and-cash/sqm-banking-info-form/readme.md | Documents the newly added per-field error-message props. |
| packages/mint-components/src/components/sqm-stencilbook/sqm-stencilbook.tsx | Registers Partner Info Modal stories in the stencilbook. |
| packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal.tsx | Adds new Partner Info Modal component (currently demo-hook driven). |
| packages/mint-components/src/components/sqm-partner-info-modal/sqm-partner-info-modal-view.tsx | Implements the modal UI (dialog + selects + submit button). |
| packages/mint-components/src/components/sqm-partner-info-modal/PartnerInfoModal.stories.tsx | Adds Storybook stories for the Partner Info Modal. |
| packages/mint-components/src/components.d.ts | Updates generated component typings for new/updated props and new component. |
| packages/mint-components/package.json | Bumps package version to 2.1.8-0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const mappedValidationErrors = validationErrors?.reduce( | ||
| (agg, error) => { | ||
| const formField = | ||
| API_FIELD_TO_FORM_FIELD[error.field] || error.field; | ||
| const errorCode = | ||
| API_ERROR_PATH_TO_FRONTEND[error.errorPath] || error.errorPath; | ||
| return { | ||
| ...agg, | ||
|
|
||
| [error.field]: { | ||
| [formField]: { | ||
| type: "invalid", | ||
| errorCode, | ||
| }, |
There was a problem hiding this comment.
errorCode falls back to error.errorPath, but the UI templates’ other branch is documented to display the raw API message. Falling back to the dot-delimited path will surface non-user-friendly strings like withdrawal.settings.error.routingcode in the UI. Consider using error.message as the fallback display value (or pass both a stable code and a human message separately).
| helpText: getValidationErrorMessage({ | ||
| type: errors?.inputErrors?.beneficiaryTaxPayerId?.type, | ||
| label: props.text.taxPayerIdLabel, | ||
| errorCode: errors?.inputErrors?.taxPayerId?.errorCode, |
There was a problem hiding this comment.
The Tax Payer ID field’s error helper reads errors?.inputErrors?.taxPayerId?.errorCode, but the error being checked is errors?.inputErrors?.beneficiaryTaxPayerId. This prevents the specific API errorCode from being shown for this field. Use the same key (beneficiaryTaxPayerId) when reading errorCode (while still using fieldName: "taxPayerId" if you intend to select the taxPayerId template).
| errorCode: errors?.inputErrors?.taxPayerId?.errorCode, | |
| errorCode: errors?.inputErrors?.beneficiaryTaxPayerId?.errorCode, |
…eUserInfoForm and useTaxAndCash step logic. Handle Impact API sending back DZ and 000000 for phone number data
…h/program-tools into cash-reward-redemption
… partner creation in to widget verificaiton flow. Some view refactors for all 3 components
…o handle country that has states.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 25 changed files in this pull request and generated 13 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const errorCode = | ||
| API_ERROR_PATH_TO_FRONTEND[error.errorPath] || error.errorPath; |
There was a problem hiding this comment.
validationErrors from the withdrawal settings mutations are queried with a code field, but the local TS types and mapping logic expect errorPath (and read error.errorPath). As written, errorCode will always be undefined, so the new per-field ICU error messaging won’t work.
Update the result types to use code (or whatever the API actually returns) and map errorCode from that field (with a sensible fallback like error.message for unknown codes).
| const errorCode = | |
| API_ERROR_PATH_TO_FRONTEND[error.errorPath] || error.errorPath; | |
| const apiErrorCode = error.code; | |
| const errorCode = | |
| API_ERROR_PATH_TO_FRONTEND[apiErrorCode] || | |
| apiErrorCode || | |
| error.message; |
| typeof props["sqm-partner-info-modal_stateController"] === "string" | ||
| ? parseStates(props["sqm-partner-info-modal_stateController"]) | ||
| : props["sqm-partner-info-modal_stateController"]; | ||
| if (props.showPartnerModal && partnerState?.states?.open === false) { |
There was a problem hiding this comment.
partnerState?.states?.open is read here, but props["sqm-partner-info-modal_stateController"] is a stateController object/string, not the hook result from usePartnerInfoModal (which is what would contain states.open). This check will always be undefined and won’t correctly hide/show the partner step in editor previews.
Either parse the partner modal’s stateController into the expected shape (e.g., look for partnerState?.["sqm-partner-info-modal"]?.states?.open) or remove this conditional.
| if (props.showPartnerModal && partnerState?.states?.open === false) { | |
| const partnerModalOpen = | |
| partnerState?.states?.open ?? | |
| partnerState?.["sqm-partner-info-modal"]?.states?.open; | |
| if (props.showPartnerModal && partnerModalOpen === false) { |
| const renderStepContent = () => { | ||
| if (props.showPartnerModal) { | ||
| return ( | ||
| <sqm-partner-info-modal | ||
| inModal | ||
| {...partnerText} | ||
| stateController={ | ||
| props["sqm-partner-info-modal_stateController"] || "{}" | ||
| } | ||
| ></sqm-partner-info-modal> | ||
| ); |
There was a problem hiding this comment.
When showPartnerModal is true, the widget always renders <sqm-partner-info-modal inModal ...>, but there’s no way for the partner modal to signal completion back to useWidgetVerification (which exposes onPartnerModalComplete) and no code here calls it. This risks trapping the user in the partner-creation step even after the connection is created.
Wire onPartnerModalComplete into the partner modal step (e.g., via an event/callback prop) and/or hide the partner content when the partner modal reports states.open === false/success === true.
| @@ -542,11 +549,11 @@ export const UserInfoFormView = (props: UserInfoFormViewProps) => { | |||
| {text.supportLink} | |||
| </a> | |||
| ), | |||
| } | |||
| }, | |||
| )} | |||
| </p> | |||
| </sqm-form-message> | |||
| )} | |||
| )} */} | |||
There was a problem hiding this comment.
This commented-out JSX block leaves a large chunk of dead UI in the render tree, which makes future maintenance harder (and can easily get out of sync with real behavior).
If this alert is no longer needed, delete the block entirely; if it may come back, consider a feature flag or a prop-driven conditional instead of keeping commented code.
| export function PartnerInfoModalContentView(props: PartnerInfoModalViewProps) { | ||
| const { states, callbacks, text } = props; | ||
| const sheet = createStyleSheet(style); | ||
| const styleString = sheet.toString(); | ||
|
|
||
| const description = states.isExistingPartner ? ( | ||
| <span class={sheet.classes.DescriptionContainer}> | ||
| <p>{text.descriptionExistingPartner}</p> | ||
| <p>{text.supportDescriptionExistingPartner}</p> | ||
| </span> | ||
| ) : ( | ||
| <p class={sheet.classes.DescriptionContainer}> | ||
| {text.descriptionNewPartner} | ||
| </p> | ||
| ); | ||
|
|
||
| const buttonLabel = states.isExistingPartner | ||
| ? text.confirmButtonLabel | ||
| : text.submitButtonLabel; | ||
|
|
||
| return ( | ||
| <div> | ||
| <style type="text/css"> {styleString}</style> | ||
| <div class={sheet.classes.FormFields}> |
There was a problem hiding this comment.
PartnerInfoModalContentView doesn’t respect states.open at all. When this component is embedded via inModal (as it is in sqm-widget-verification), the content will still render even after usePartnerInfoModal sets open to false on success.
Add an early return when !states.open (or when states.success is true), or provide a callback/event so the parent can switch steps once partner creation completes.
| console.log(countryCode, currency, "initial country and currency state"); // TEMP | ||
| const [countrySearch, setCountrySearch] = useState(""); | ||
| const [currencySearch, setCurrencySearch] = useState(""); | ||
| const [filteredCountries, setFilteredCountries] = useState( | ||
| countriesData?.impactPayoutCountries?.data || [], | ||
| ); | ||
| const [filteredCurrencies, setFilteredCurrencies] = useState( | ||
| currencies || [], | ||
| ); | ||
|
|
||
| console.log(userData, "userData partner info modal"); | ||
|
|
There was a problem hiding this comment.
Multiple console.log statements in this hook (marked // TEMP) will leak user/account data to the browser console and add noise in production. Please remove these logs before merge.
| const [success, setSuccess] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| if (userData && user.impactConnection?.publisher) { |
There was a problem hiding this comment.
This effect condition uses user.impactConnection?.publisher, but user can be undefined while data is still loading or if the viewer isn’t a User. Accessing user.impactConnection will throw at runtime.
Change the guard to if (userData && user?.impactConnection?.publisher) { ... } (or just if (user?.impactConnection?.publisher) ...) and consider disabling submit until user is available, since onSubmit also assumes user.id/accountId exist.
| if (userData && user.impactConnection?.publisher) { | |
| if (userData && user?.impactConnection?.publisher) { |
| header-new-partner="Welcome to {brandName} Program!" | ||
| description-new-partner="We just need a bit more information about you before you start earning cash!" |
There was a problem hiding this comment.
<sqm-partner-info-modal> props in this story use attribute names that don’t exist on the component (e.g. header-new-partner). The actual prop/attribute is modalHeader / modal-header.
Update the story to use the real attribute names so the example renders correctly.
| header-new-partner="Welcome to {brandName} Program!" | |
| description-new-partner="We just need a bit more information about you before you start earning cash!" | |
| modal-header="Welcome to {brandName} Program!" | |
| modal-description="We just need a bit more information about you before you start earning cash!" |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 26 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (1)
packages/mint-components/src/components/tax-and-cash/sqm-banking-info-form/useBankingInfoForm.tsx:276
validationErrorsis queried with acodefield (seeSAVE_WITHDRAWAL_SETTINGS/UPDATE_WITHDRAWAL_SETTINGS), but the TS result types and the mapping logic later in the file expecterrorPath(error.errorPath). At runtimeerrorPathwill beundefined, soerrorCodewill be missing and field-level error messages won’t render correctly. Update the result types and the reducer mapping to use the actual GraphQL field name (likelycode), and useerror.messageas the fallback display value when the code isn’t mapped.
type SetImpactPublisherWithdrawalSettingsResult = {
setImpactPublisherWithdrawalSettings: {
success: boolean;
validationErrors: {
field: string;
message: string;
errorPath: string;
}[];
};
};
type SetImpactPublisherWithdrawalSettingsInput = {
user: {
id: string;
accountId: string;
};
paymentMethod: "PAYPAL" | "BANKING_TRANSFER";
paymentSchedulingType?: "BALANCE_THRESHOLD" | "FIXED_DAY";
paymentThreshold?: string;
paymentDay?: string;
} & BankingInfoFormData;
type UpdateImpactPublisherWithdrawalSettingsInput =
SetImpactPublisherWithdrawalSettingsInput & { accessKey: string };
type UpdateImpactPublisherWithdrawalSettingsResult = {
updateImpactPublisherWithdrawalSettings: {
success: boolean;
validationErrors: {
field: string;
message: string;
errorPath: string;
}[];
};
};
const SAVE_WITHDRAWAL_SETTINGS = gql`
mutation setImpactPublisherWithdrawalSettings(
$setImpactPublisherWithdrawalSettingsInput: SetImpactPublisherWithdrawalSettingsInput!
) {
setImpactPublisherWithdrawalSettings(
setImpactPublisherWithdrawalSettingsInput: $setImpactPublisherWithdrawalSettingsInput
) {
success
validationErrors {
field
message
code
}
}
}
`;
const UPDATE_WITHDRAWAL_SETTINGS = gql`
mutation updateImpactPublisherWithdrawalSettings(
$updateImpactPublisherWithdrawalSettingsInput: UpdateImpactPublisherWithdrawalSettingsInput!
) {
updateImpactPublisherWithdrawalSettings(
updateImpactPublisherWithdrawalSettingsInput: $updateImpactPublisherWithdrawalSettingsInput
) {
success
validationErrors {
field
message
code
}
}
}
`;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let result = null; | ||
| let connectionResult; | ||
| if (userData?.impactConnection?.connectionStatus === "STARTED") { | ||
| console.log(vars, "values for completeImpactPartner"); |
There was a problem hiding this comment.
There’s a console.log left in the submit path when connectionStatus === "STARTED". This will leak PII (names/address/phone) into the browser console and shouldn’t ship in a shared component library. Please remove the log (and any similar debug logging) or gate it behind a debug flag that’s disabled in production builds.
| console.log(vars, "values for completeImpactPartner"); |
| //AL: TODO completePartnerMutation might change | ||
| let result = null; | ||
| let connectionResult; | ||
| if (userData?.user?.impactConnection?.connected) { | ||
| result = await completeImpactPartner({ | ||
| vars, | ||
| }); | ||
| connectionResult = (result as CompletePartnerResult) | ||
| ?.completeImpactConnection; | ||
| } else { | ||
| result = await connectImpactPartner({ | ||
| vars, | ||
| }); | ||
| connectionResult = (result as ConnectPartnerResult) | ||
| ?.createImpactConnection; | ||
| } |
There was a problem hiding this comment.
The decision to call completeImpactPartner is currently based on impactConnection.connected. In the new flow this PR introduces, connectionStatus can be STARTED while connected is still false, which would incorrectly re-run createImpactConnection instead of completing/updating the existing connection. Consider switching this condition to use impactConnection.connectionStatus === "STARTED" (consistent with useUserInfoForm) so you don’t attempt a second create for an already-started connection.
| const USER_LOOKUP = gql` | ||
| query checkUserVerification { | ||
| viewer { | ||
| ... on User { | ||
| id | ||
| accountId | ||
| emailVerified | ||
| managedIdentity { | ||
| emailVerified | ||
| } | ||
| impactConnection { | ||
| connected | ||
| } | ||
| } | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| export function useWidgetVerification() { | ||
| const userIdentity = useUserIdentity(); | ||
| const [showCode, setShowCode] = useParentState<boolean>({ | ||
| namespace: SHOW_CODE_NAMESPACE, | ||
| initialValue: false, | ||
| }); | ||
| const [email, setEmail] = useParentState<string | undefined>({ | ||
| namespace: VERIFICATION_EMAIL_NAMESPACE, | ||
| initialValue: userIdentity?.email, | ||
| }); | ||
| const setContext = useSetParent(VERIFICATION_PARENT_NAMESPACE); | ||
| const [loading, setLoading] = useState(true); | ||
| const [showPartnerModal, setShowPartnerModal] = useState(false); | ||
| const [fetch] = useLazyQuery(USER_LOOKUP); | ||
|
|
||
| useEffect(() => { | ||
| const checkUser = async () => { | ||
| try { | ||
| 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); | ||
| } |
There was a problem hiding this comment.
useWidgetVerification only checks impactConnection.connected to decide whether to bypass verification / partner creation. With the new connectionStatus flow, a user can be STARTED but not connected (e.g., after early partner creation but before completion), which would currently force showPartnerModal even though sqm-partner-info-modal only opens on NOT_STARTED—resulting in an empty modal and a blocked flow. Fetch connectionStatus in USER_LOOKUP and update the logic so STARTED/COMPLETED goes straight to setContext(true), and only NOT_STARTED shows the partner modal after email verification.
| export const Step3PartnerModal = () => ( | ||
| <sqm-widget-verification | ||
| stateController={JSON.stringify({ | ||
| "sqm-widget-verification": { showPartnerModal: true }, |
There was a problem hiding this comment.
Step3PartnerModal sets only showPartnerModal: true on sqm-widget-verification, but it does not provide any state for sqm-partner-info-modal itself. In demo mode that modal defaults to open: false, so this story will likely render an empty dialog. Consider also passing "sqm-partner-info-modal": { states: { open: true } } (or similar) in stateController so the story reliably displays the partner creation content.
| "sqm-widget-verification": { showPartnerModal: true }, | |
| "sqm-widget-verification": { showPartnerModal: true }, | |
| "sqm-partner-info-modal": { states: { open: true } }, |
| async function onSubmit() { | ||
| if (!allowBankingCollection) { | ||
| setCheckboxError(props.missingFieldsErrorText); |
There was a problem hiding this comment.
When the terms/consent checkbox isn’t checked, the code sets checkboxError to props.missingFieldsErrorText, which reads like a country/currency validation message. This is confusing for users and makes localization harder. Add a dedicated error string for the unchecked consent case (or reuse an existing consent-specific error prop) and use that for checkboxError instead.
| async function onSubmit() { | |
| if (!allowBankingCollection) { | |
| setCheckboxError(props.missingFieldsErrorText); | |
| function getConsentCheckboxErrorText() { | |
| const textProps = (props.getTextProps?.() as any) || {}; | |
| return ( | |
| textProps.consentCheckboxErrorText || | |
| textProps.consentErrorText || | |
| "Please accept the consent checkbox to continue." | |
| ); | |
| } | |
| async function onSubmit() { | |
| if (!allowBankingCollection) { | |
| setCheckboxError(getConsentCheckboxErrorText()); |
| // when creating an impact connection without sending phoneNumber data, the impactAPI defaults the value to "000000" 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, |
There was a problem hiding this comment.
The comment says the Impact API defaults phoneNumber to "000000", but the actual check is against "0000000". If the default is 6 zeros, this condition will never trigger and you’ll keep the bogus phone/country-code values (and potentially keep the phone fields disabled). Please confirm the exact sentinel value and make the comment + check consistent (ideally centralize the sentinel as a constant used by both view + hook).
…modal. Update story naming to match designs
| // If we have a specific error code from the API, try to use | ||
| // the per-field ICU error message template for a rich message | ||
| if (type === "invalid" && errorCode && fieldName) { | ||
| const errorTemplate = props.text.errorMessages?.[fieldName]; | ||
| if (errorTemplate) { |
| async function onSubmit() { | ||
| if (!allowBankingCollection) { | ||
| setError(props.missingFieldsErrorText); |
| const showModal = | ||
| !success && | ||
| !userLoading && | ||
| impactConnection?.connectionStatus === "NOT_STARTED"; |
| // when creating an impact connection without sending phoneNumber data, the impactAPI defaults the value to "000000" and the phoneNumberCountryCode to "DZ" | ||
| function isDisabledPartnerInput(field: string) { | ||
| if ( | ||
| data.partnerData?.phoneNumber === "0000000" && | ||
| (field === "phoneNumber" || field === "phoneNumberCountryCode") | ||
| ) { |
| {/* AL: Don't need to show this anymore due to early partner creation */} | ||
| {/* {(states.isPartner || states.isUser) && ( | ||
| <sqm-form-message loading={states.loading} type="info"> | ||
| <p part="alert-title">{text.isPartnerAlertHeader}</p> | ||
| <p part="alert-description"> |
| {/* <div class={classes.CheckboxWrapper}> | ||
| AL: FLAGGED FOR DELETION | ||
| <sl-checkbox | ||
| checked={formState.allowBankingCollection === true} | ||
| onSl-change={(e) => { |
|
|
||
| return ( | ||
| <span class={sheet.classes[type]} part="sqm-base"> | ||
| <span class={sheet.classes[type]} part="sqm-text-span-base"> |
| validationErrors { | ||
| field | ||
| message | ||
| code | ||
| } |
| errorCode: errors?.inputErrors?.taxPayerId?.errorCode, | ||
| fieldName: "taxPayerId", |
| async function onSubmit() { | ||
| if (!allowBankingCollection) { | ||
| setError(props.missingFieldsErrorText); | ||
| return; |
| /** | ||
| * Header text when user has no existing partner | ||
| * | ||
| * @uiName New partner header | ||
| * @uiWidget textArea | ||
| */ | ||
| @Prop() | ||
| modalHeader: string = "Let's get you ready for rewards"; |
| const defaultProps: PartnerInfoModalViewProps = { | ||
| states: { | ||
| open: true, | ||
| loading: false, | ||
| submitting: false, | ||
| isExistingPartner: false, | ||
| countryCode: "", | ||
| currency: "", | ||
| error: "", | ||
| success: false, | ||
| brandName: "Test Brand", | ||
| filteredCountries: demoCountries, | ||
| filteredCurrencies: demoCurrencies, | ||
| allowBankingCollection: false, | ||
| disabled: false, |
|
|
||
| return ( | ||
| <span class={sheet.classes[type]} part="sqm-base"> | ||
| <span class={sheet.classes[type]} part="sqm-text-span-base"> |
… if withdrawal settings are not present in rewards table status column
… in sqm-rewards-table-status-cell and sqm-referral-table-rewards-column. Added new spec sheets to cover logic of functions
Description of the change
Type of change
Links
Checklists
Development
Paperwork
Code review