mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
[MM-51236] - Add opt-in checkbox in self-hosted account creation page (#22709)
* [MM-51236] - Add opt-in checkbox in self-hosted account creation page * add server impl * fix lint and translations * add tests * improvents * fix model * fix mocks * feedback impl * update text color * handle error * fix tests
This commit is contained in:
@@ -8,6 +8,12 @@ type BootstrapSelfHostedSignupRequest struct {
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
type SubscribeNewsletterRequest struct {
|
||||
Email string `json:"email"`
|
||||
ServerID string `json:"server_id"`
|
||||
SubscribedContent string `json:"subscribed_content"`
|
||||
}
|
||||
|
||||
type BootstrapSelfHostedSignupResponse struct {
|
||||
Progress string `json:"progress"`
|
||||
// email listed on the JWT claim
|
||||
|
||||
@@ -36,6 +36,8 @@ func (api *API) InitHostedCustomer() {
|
||||
api.BaseRoutes.HostedCustomer.Handle("/invoices", api.APISessionRequired(selfHostedInvoices)).Methods("GET")
|
||||
// GET /api/v4/hosted_customer/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf
|
||||
api.BaseRoutes.HostedCustomer.Handle("/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.APISessionRequired(selfHostedInvoicePDF)).Methods("GET")
|
||||
|
||||
api.BaseRoutes.HostedCustomer.Handle("/subscribe-newsletter", api.APIHandler(handleSubscribeToNewsletter)).Methods(http.MethodPost)
|
||||
}
|
||||
|
||||
func ensureSelfHostedAdmin(c *Context, where string) {
|
||||
@@ -293,3 +295,33 @@ func selfHostedInvoicePDF(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
r,
|
||||
)
|
||||
}
|
||||
|
||||
func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
const where = "Api4.handleSubscribeToNewsletter"
|
||||
ensured := ensureCloudInterface(c, where)
|
||||
if !ensured {
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(model.SubscribeNewsletterRequest)
|
||||
err = json.Unmarshal(bodyBytes, req)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
req.ServerID = c.App.Srv().TelemetryId()
|
||||
|
||||
if err := c.App.Cloud().SubscribeToNewsletter("", req); err != nil {
|
||||
c.Err = model.NewAppError(where, "api.server.cws.subscribe_to_newsletter.app_error", nil, "CWS Server failed to subscribe to newsletter.", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
@@ -47,4 +47,5 @@ type CloudInterface interface {
|
||||
CheckCWSConnection(userId string) error
|
||||
|
||||
SelfServeDeleteWorkspace(userID string, deletionRequest *model.WorkspaceDeletionRequest) error
|
||||
SubscribeToNewsletter(userID string, req *model.SubscribeNewsletterRequest) error
|
||||
}
|
||||
|
||||
@@ -540,6 +540,20 @@ func (_m *CloudInterface) SelfServeDeleteWorkspace(userID string, deletionReques
|
||||
return r0
|
||||
}
|
||||
|
||||
// SubscribeToNewsletter provides a mock function with given fields: userID, req
|
||||
func (_m *CloudInterface) SubscribeToNewsletter(userID string, req *model.SubscribeNewsletterRequest) error {
|
||||
ret := _m.Called(userID, req)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *model.SubscribeNewsletterRequest) error); ok {
|
||||
r0 = rf(userID, req)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UpdateCloudCustomer provides a mock function with given fields: userID, customerInfo
|
||||
func (_m *CloudInterface) UpdateCloudCustomer(userID string, customerInfo *model.CloudCustomerInfo) (*model.CloudCustomer, error) {
|
||||
ret := _m.Called(userID, customerInfo)
|
||||
|
||||
@@ -2591,6 +2591,10 @@
|
||||
"id": "api.server.cws.needs_enterprise_edition",
|
||||
"translation": "Service only available in Mattermost Enterprise edition"
|
||||
},
|
||||
{
|
||||
"id": "api.server.cws.subscribe_to_newsletter.app_error",
|
||||
"translation": "CWS Server failed to subscribe to newsletter."
|
||||
},
|
||||
{
|
||||
"id": "api.server.hosted_signup_unavailable.error",
|
||||
"translation": "Portal unavailable for self-hosted signup."
|
||||
|
||||
@@ -93,6 +93,28 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
onChange={[Function]}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="newsletter"
|
||||
>
|
||||
<span
|
||||
className="interested"
|
||||
>
|
||||
Interested in receiving Mattermost security updates via newsletter?
|
||||
</span>
|
||||
<span
|
||||
className="link"
|
||||
>
|
||||
Sign up at
|
||||
<ExternalLink
|
||||
href="https://mattermost.com/security-updates/"
|
||||
key=".1"
|
||||
location="signup"
|
||||
>
|
||||
https://mattermost.com/security-updates/
|
||||
</ExternalLink>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
<SaveButton
|
||||
btnClass="btn-primary"
|
||||
defaultMessage="Create account"
|
||||
@@ -244,6 +266,28 @@ exports[`components/signup/Signup should match snapshot for all signup options e
|
||||
onChange={[Function]}
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="newsletter"
|
||||
>
|
||||
<span
|
||||
className="interested"
|
||||
>
|
||||
Interested in receiving Mattermost security updates via newsletter?
|
||||
</span>
|
||||
<span
|
||||
className="link"
|
||||
>
|
||||
Sign up at
|
||||
<ExternalLink
|
||||
href="https://mattermost.com/security-updates/"
|
||||
key=".1"
|
||||
location="signup"
|
||||
>
|
||||
https://mattermost.com/security-updates/
|
||||
</ExternalLink>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
<SaveButton
|
||||
btnClass="btn-primary"
|
||||
defaultMessage="Create account"
|
||||
|
||||
@@ -166,6 +166,22 @@
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.newsletter {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 32px;
|
||||
color: var(--center-channel-color-rgb);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
|
||||
.interested {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.signup-body-card-form-button-submit {
|
||||
@include primary-button;
|
||||
@include button-large;
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from 'react';
|
||||
import {shallow, ReactWrapper} from 'enzyme';
|
||||
import {IntlProvider} from 'react-intl';
|
||||
import {BrowserRouter} from 'react-router-dom';
|
||||
import {act} from '@testing-library/react';
|
||||
import {act, screen} from '@testing-library/react';
|
||||
|
||||
import * as global_actions from 'actions/global_actions';
|
||||
|
||||
@@ -15,16 +15,18 @@ import Signup from 'components/signup/signup';
|
||||
import Input from 'components/widgets/inputs/input/input';
|
||||
import PasswordInput from 'components/widgets/inputs/password_input/password_input';
|
||||
import SaveButton from 'components/save_button';
|
||||
import * as useCWSAvailabilityCheckAll from 'components/common/hooks/useCWSAvailabilityCheck';
|
||||
|
||||
import {RequestStatus} from 'mattermost-redux/constants';
|
||||
import {ClientConfig} from '@mattermost/types/config';
|
||||
import {GlobalState} from 'types/store';
|
||||
import {WindowSizes} from 'utils/constants';
|
||||
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
|
||||
|
||||
let mockState: GlobalState;
|
||||
let mockLocation = {pathname: '', search: '', hash: ''};
|
||||
const mockHistoryPush = jest.fn();
|
||||
let mockLicense = {IsLicensed: 'true'};
|
||||
let mockLicense = {IsLicensed: 'true', Cloud: 'false'};
|
||||
let mockConfig: Partial<ClientConfig>;
|
||||
let mockDispatch = jest.fn();
|
||||
|
||||
@@ -96,7 +98,7 @@ describe('components/signup/Signup', () => {
|
||||
beforeEach(() => {
|
||||
mockLocation = {pathname: '', search: '', hash: ''};
|
||||
|
||||
mockLicense = {IsLicensed: 'true'};
|
||||
mockLicense = {IsLicensed: 'true', Cloud: 'false'};
|
||||
|
||||
mockState = {
|
||||
entities: {
|
||||
@@ -178,7 +180,7 @@ describe('components/signup/Signup', () => {
|
||||
});
|
||||
|
||||
it('should match snapshot for all signup options enabled with isLicensed disabled', () => {
|
||||
mockLicense = {IsLicensed: 'false'};
|
||||
mockLicense = {IsLicensed: 'false', Cloud: 'false'};
|
||||
|
||||
const wrapper = shallow(
|
||||
<Signup/>,
|
||||
@@ -295,4 +297,45 @@ describe('components/signup/Signup', () => {
|
||||
expect(wrapper.find('.content-layout-column-title').text()).toEqual('This invite link is invalid');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show newsletter check box opt-in for self-hosted non airgapped workspaces', async () => {
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => true);
|
||||
mockLicense = {IsLicensed: 'true', Cloud: 'false'};
|
||||
|
||||
const {container: signupContainer} = renderWithIntlAndStore(
|
||||
<BrowserRouter>
|
||||
<Signup/>
|
||||
</BrowserRouter>, {});
|
||||
|
||||
screen.getByTestId('signup-body-card-form-check-newsletter');
|
||||
const checkInput = screen.getByTestId('signup-body-card-form-check-newsletter');
|
||||
expect(checkInput).toHaveAttribute('type', 'checkbox');
|
||||
|
||||
expect(signupContainer).toHaveTextContent(/I would like to receive Mattermost security updates via newsletter. Data Terms and Conditions apply/);
|
||||
});
|
||||
|
||||
it('should NOT show newsletter check box opt-in for self-hosted AND airgapped workspaces', async () => {
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => false);
|
||||
mockLicense = {IsLicensed: 'true', Cloud: 'false'};
|
||||
|
||||
const {container: signupContainer} = renderWithIntlAndStore(
|
||||
<BrowserRouter>
|
||||
<Signup/>
|
||||
</BrowserRouter>, {});
|
||||
|
||||
expect(() => screen.getByTestId('signup-body-card-form-check-newsletter')).toThrow();
|
||||
expect(signupContainer).toHaveTextContent('Interested in receiving Mattermost security updates via newsletter?Sign up at https://mattermost.com/security-updates/.');
|
||||
});
|
||||
|
||||
it('should not show any newsletter related opt-in or text for cloud', async () => {
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => true);
|
||||
mockLicense = {IsLicensed: 'true', Cloud: 'true'};
|
||||
|
||||
renderWithIntlAndStore(
|
||||
<BrowserRouter>
|
||||
<Signup/>
|
||||
</BrowserRouter>, {});
|
||||
|
||||
expect(() => screen.getByTestId('signup-body-card-form-check-newsletter')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,9 +48,12 @@ import LoginOpenIDIcon from 'components/widgets/icons/login_openid_icon';
|
||||
import LoginOffice365Icon from 'components/widgets/icons/login_office_365_icon';
|
||||
import Input, {CustomMessageInputType, SIZE} from 'components/widgets/inputs/input/input';
|
||||
import PasswordInput from 'components/widgets/inputs/password_input/password_input';
|
||||
import CheckInput from 'components/widgets/inputs/check';
|
||||
import SaveButton from 'components/save_button';
|
||||
import useCWSAvailabilityCheck from 'components/common/hooks/useCWSAvailabilityCheck';
|
||||
import ExternalLink from 'components/external_link';
|
||||
|
||||
import {Constants, ItemStatus, ValidationErrors} from 'utils/constants';
|
||||
import {Constants, HostedCustomerLinks, ItemStatus, ValidationErrors} from 'utils/constants';
|
||||
import {isValidUsername, isValidPassword, getPasswordConfig, getRoleFromTrackFlow, getMediumFromTrackFlow} from 'utils/utils';
|
||||
|
||||
import './signup.scss';
|
||||
@@ -99,7 +102,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
TermsOfServiceLink,
|
||||
PrivacyPolicyLink,
|
||||
} = config;
|
||||
const {IsLicensed} = useSelector(getLicense);
|
||||
const {IsLicensed, Cloud} = useSelector(getLicense);
|
||||
const loggedIn = Boolean(useSelector(getCurrentUserId));
|
||||
const useCaseOnboarding = useSelector(getUseCaseOnboarding);
|
||||
const usedBefore = useSelector((state: GlobalState) => (!inviteId && !loggedIn && token ? getGlobalItem(state, token, null) : undefined));
|
||||
@@ -110,6 +113,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isLicensed = IsLicensed === 'true';
|
||||
const isCloud = Cloud === 'true';
|
||||
const enableOpenServer = EnableOpenServer === 'true';
|
||||
const noAccounts = NoAccounts === 'true';
|
||||
const enableSignUpWithEmail = EnableSignUpWithEmail === 'true';
|
||||
@@ -136,12 +140,24 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
const [teamName, setTeamName] = useState(parsedTeamName ?? '');
|
||||
const [alertBanner, setAlertBanner] = useState<AlertBannerProps | null>(null);
|
||||
const [isMobileView, setIsMobileView] = useState(false);
|
||||
const [subscribeToSecurityNewsletter, setSubscribeToSecurityNewsletter] = useState(false);
|
||||
|
||||
const canReachCWS = useCWSAvailabilityCheck();
|
||||
|
||||
const enableExternalSignup = enableSignUpWithGitLab || enableSignUpWithOffice365 || enableSignUpWithGoogle || enableSignUpWithOpenId || enableLDAP || enableSAML;
|
||||
const hasError = Boolean(emailError || nameError || passwordError || serverError || alertBanner);
|
||||
const canSubmit = Boolean(email && name && password) && !hasError && !loading;
|
||||
const {error: passwordInfo} = isValidPassword('', getPasswordConfig(config), intl);
|
||||
|
||||
const subscribeToSecurityNewsletterFunc = () => {
|
||||
try {
|
||||
Client4.subscribeToNewsletter({email, subscribed_content: 'security_newsletter'});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getExternalSignupOptions = () => {
|
||||
const externalLoginOptions: ExternalLoginButtonType[] = [];
|
||||
|
||||
@@ -564,6 +580,9 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
}
|
||||
|
||||
await handleSignupSuccess(user, data as UserProfile);
|
||||
if (subscribeToSecurityNewsletter) {
|
||||
subscribeToSecurityNewsletterFunc();
|
||||
}
|
||||
} else {
|
||||
setIsWaiting(false);
|
||||
}
|
||||
@@ -571,6 +590,60 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
|
||||
const handleReturnButtonOnClick = () => history.replace('/');
|
||||
|
||||
const getNewsletterCheck = () => {
|
||||
if (isCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (canReachCWS) {
|
||||
return (
|
||||
<CheckInput
|
||||
id='signup-body-card-form-check-newsletter'
|
||||
name='newsletter'
|
||||
onChange={() => setSubscribeToSecurityNewsletter(!subscribeToSecurityNewsletter)}
|
||||
text={
|
||||
formatMessage(
|
||||
{id: 'newsletter_optin.checkmark.text', defaultMessage: 'I would like to receive Mattermost security updates via newsletter. Data <a>Terms and Conditions</a> apply'},
|
||||
{
|
||||
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
|
||||
<ExternalLink
|
||||
location='signup-newsletter-checkmark'
|
||||
href={HostedCustomerLinks.SECURITY_UPDATES}
|
||||
>
|
||||
{chunks}
|
||||
</ExternalLink>
|
||||
),
|
||||
},
|
||||
)}
|
||||
checked={subscribeToSecurityNewsletter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='newsletter'>
|
||||
<span className='interested'>
|
||||
{formatMessage({id: 'newsletter_optin.title', defaultMessage: 'Interested in receiving Mattermost security updates via newsletter?'})}
|
||||
</span>
|
||||
<span className='link'>
|
||||
{formatMessage(
|
||||
{id: 'newsletter_optin.desc', defaultMessage: 'Sign up at <a>{link}</a>.'},
|
||||
{
|
||||
link: HostedCustomerLinks.SECURITY_UPDATES,
|
||||
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
|
||||
<ExternalLink
|
||||
location='signup'
|
||||
href={HostedCustomerLinks.SECURITY_UPDATES}
|
||||
>
|
||||
{chunks}
|
||||
</ExternalLink>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleOnBlur = (e: FocusEvent<HTMLInputElement>, inputId: string) => {
|
||||
const text = e.target.value;
|
||||
if (!text) {
|
||||
@@ -736,6 +809,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => {
|
||||
error={passwordError}
|
||||
onBlur={(e) => handleOnBlur(e, 'password')}
|
||||
/>
|
||||
{getNewsletterCheck()}
|
||||
<SaveButton
|
||||
extraClasses='signup-body-card-form-button-submit large'
|
||||
saving={isWaiting}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.check-input {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.text {
|
||||
margin-left: 8px;
|
||||
color: var(--center-channel-color-rgb);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {ReactNode} from 'react';
|
||||
import './check.scss';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
name: string;
|
||||
text: ReactNode;
|
||||
onChange: () => void;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
function CheckInput(props: Props) {
|
||||
return (
|
||||
<div className='check-input'>
|
||||
<input
|
||||
{...props}
|
||||
data-testid={props.id}
|
||||
type='checkbox'
|
||||
/>
|
||||
<span className='text'>{props.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckInput;
|
||||
@@ -4254,6 +4254,9 @@
|
||||
"navbar.viewPinnedPosts": "View Pinned Posts",
|
||||
"newChannelWithBoard.tutorialTip.description": "The board you just created can be quickly accessed by clicking on the Boards icon in the App bar. You can view the boards that are linked to this channel in the right-hand sidebar and open one in full view.",
|
||||
"newChannelWithBoard.tutorialTip.title": "Access linked boards from the App Bar",
|
||||
"newsletter_optin.checkmark.text": "I would like to receive Mattermost security updates via newsletter. Data <a>Terms and Conditions</a> apply",
|
||||
"newsletter_optin.desc": "Sign up at <a>{link}</a>.",
|
||||
"newsletter_optin.title": "Interested in receiving Mattermost security updates via newsletter?",
|
||||
"next_steps_view.welcomeToMattermost": "Welcome to Mattermost",
|
||||
"no_results.channel_files_filtered.subtitle": "This channel doesn't contains any file with the selected file format.",
|
||||
"no_results.channel_files_filtered.title": "No files found",
|
||||
|
||||
@@ -1077,6 +1077,7 @@ export const HostedCustomerLinks = {
|
||||
BILLING_DOCS: 'https://mattermost.com/pl/how-self-hosted-billing-works',
|
||||
SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html',
|
||||
TERMS_AND_CONDITIONS: 'https://mattermost.com/enterprise-edition-terms/',
|
||||
SECURITY_UPDATES: 'https://mattermost.com/security-updates/',
|
||||
};
|
||||
|
||||
export const DocLinks = {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
CreateSubscriptionRequest,
|
||||
Feedback,
|
||||
WorkspaceDeletionRequest,
|
||||
NewsletterRequestBody,
|
||||
} from '@mattermost/types/cloud';
|
||||
import {
|
||||
SelfHostedSignupForm,
|
||||
@@ -3893,6 +3894,13 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
subscribeToNewsletter = (newletterRequestBody: NewsletterRequestBody) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getHostedCustomerRoute()}/subscribe-newsletter`,
|
||||
{method: 'post', body: JSON.stringify(newletterRequestBody)},
|
||||
);
|
||||
};
|
||||
|
||||
createPaymentMethod = async () => {
|
||||
return this.doFetch(
|
||||
`${this.getCloudRoute()}/payment`,
|
||||
|
||||
@@ -220,6 +220,11 @@ export interface CreateSubscriptionRequest {
|
||||
internal_purchase_order?: string;
|
||||
}
|
||||
|
||||
export interface NewsletterRequestBody {
|
||||
email: string;
|
||||
subscribed_content: string;
|
||||
}
|
||||
|
||||
export const areShippingDetailsValid = (address: Address | null | undefined): boolean => {
|
||||
if (!address) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user