billingDetailsForm.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import {Fragment, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {LoadScriptNextProps} from '@react-google-maps/api';
  4. import {useLoadScript} from '@react-google-maps/api';
  5. import * as Sentry from '@sentry/react';
  6. import AutoComplete from 'sentry/components/autoComplete';
  7. import {Input} from 'sentry/components/core/input';
  8. import DropdownBubble from 'sentry/components/dropdownBubble';
  9. import type {FieldGroupProps} from 'sentry/components/forms/fieldGroup/types';
  10. import SelectField from 'sentry/components/forms/fields/selectField';
  11. import TextField from 'sentry/components/forms/fields/textField';
  12. import Form from 'sentry/components/forms/form';
  13. import FormField from 'sentry/components/forms/formField';
  14. import FormModel from 'sentry/components/forms/model';
  15. import MenuListItem from 'sentry/components/menuListItem';
  16. import {t, tct} from 'sentry/locale';
  17. import ConfigStore from 'sentry/stores/configStore';
  18. import {space} from 'sentry/styles/space';
  19. import type {Organization} from 'sentry/types/organization';
  20. import type {BillingDetails} from 'getsentry/types';
  21. import {countryCodes} from 'getsentry/utils/ISO3166codes';
  22. import type {TaxFieldInfo} from 'getsentry/utils/salesTax';
  23. import {
  24. countryHasRegionChoices,
  25. countryHasSalesTax,
  26. getRegionChoiceCode,
  27. getRegionChoices,
  28. getTaxFieldInfo,
  29. } from 'getsentry/utils/salesTax';
  30. const COUNTRY_CODE_CHOICES = countryCodes.map(({name, code}) => [code, name]);
  31. type Props = {
  32. onSubmitSuccess: (data: Record<PropertyKey, unknown>) => void;
  33. organization: Organization;
  34. /**
  35. * Additional form field props.
  36. */
  37. fieldProps?: FieldGroupProps;
  38. /**
  39. * Custom styles for the form footer.
  40. */
  41. footerStyle?: React.CSSProperties;
  42. /**
  43. * Initial form data.
  44. */
  45. initialData?: BillingDetails;
  46. /**
  47. * Display detailed view for subscription settings.
  48. */
  49. isDetailed?: boolean;
  50. onPreSubmit?: () => void;
  51. onSubmitError?: (error: any) => void;
  52. /**
  53. * Are changes required before the form can be submitted?
  54. */
  55. requireChanges?: boolean;
  56. /**
  57. * Form submit button label.
  58. */
  59. submitLabel?: string;
  60. /**
  61. * Custom wrapper for the form fields.
  62. * Defaults to `DefaultWrapper`.
  63. */
  64. wrapper?: (children: any) => React.ReactElement;
  65. };
  66. function DefaultWrapper({children}: any) {
  67. return <div>{children}</div>;
  68. }
  69. type State = {
  70. showTaxNumber: boolean;
  71. countryCode?: string | null;
  72. taxFieldInfo?: TaxFieldInfo;
  73. };
  74. const GOOGLE_MAPS_LOAD_OPTIONS: LoadScriptNextProps = {
  75. googleMapsApiKey: ConfigStore.get('getsentry.googleMapsApiKey'),
  76. libraries: ['places'],
  77. } as LoadScriptNextProps;
  78. // must be of type autoComplete.Item
  79. type PredictionItem = google.maps.places.AutocompletePrediction & {
  80. 'data-test-id'?: string;
  81. disabled?: boolean;
  82. };
  83. function transformData(data: Record<string, any>) {
  84. // Clear tax number if not applicable to country code.
  85. // This is done on save instead of on change to retain the field value
  86. // if the user makes a mistake.
  87. if (!countryHasSalesTax(data.countryCode)) {
  88. data.taxNumber = null;
  89. }
  90. // Clear the region if not applicable to country code.
  91. if (
  92. countryHasRegionChoices(data.countryCode) &&
  93. !getRegionChoiceCode(data.countryCode, data.region)
  94. ) {
  95. data.region = undefined;
  96. }
  97. return data;
  98. }
  99. /**
  100. * Billing details form to be rendered inside a panel. This is
  101. * used in checkout, legal & compliance, and subscription settings.
  102. */
  103. function BillingDetailsForm({
  104. initialData,
  105. onPreSubmit,
  106. onSubmitError,
  107. onSubmitSuccess,
  108. organization,
  109. submitLabel,
  110. footerStyle,
  111. fieldProps,
  112. requireChanges,
  113. isDetailed = true,
  114. wrapper = DefaultWrapper,
  115. }: Props) {
  116. const {isLoaded} = useLoadScript(GOOGLE_MAPS_LOAD_OPTIONS);
  117. const [form] = useState(() => new FormModel({transformData}));
  118. const autoCompleteService = useMemo(
  119. () => (isLoaded ? new google.maps.places.AutocompleteService() : null),
  120. [isLoaded]
  121. );
  122. const [state, setState] = useState<State>({
  123. countryCode: initialData?.countryCode,
  124. showTaxNumber:
  125. !!initialData?.taxNumber || countryHasSalesTax(initialData?.countryCode),
  126. });
  127. const [predictionData, setPredictionData] = useState<
  128. google.maps.places.AutocompletePrediction[]
  129. >([]);
  130. const [placeService, setPlaceService] = useState<google.maps.places.PlacesService>();
  131. const FieldWrapper = wrapper;
  132. const transformedInitialData = {
  133. ...initialData,
  134. region: countryHasRegionChoices(initialData?.countryCode)
  135. ? getRegionChoiceCode(initialData?.countryCode, initialData?.region)
  136. : initialData?.region,
  137. };
  138. const taxFieldInfo = getTaxFieldInfo(state.countryCode);
  139. const regionChoices = getRegionChoices(state.countryCode);
  140. function updateCountryCodeState(countryCode: string) {
  141. setState({
  142. ...state,
  143. countryCode,
  144. showTaxNumber: countryHasSalesTax(countryCode),
  145. });
  146. }
  147. async function handleAddressChange(e: React.ChangeEvent<HTMLInputElement>) {
  148. // AutoCompleteService library must be loaded to proceed with prediction
  149. if (!autoCompleteService) {
  150. return;
  151. }
  152. // length of input address string should be at least 5 to proceed with prediction
  153. if (!e.target.value || e.target.value.length < 5) {
  154. return;
  155. }
  156. try {
  157. const autocompleteResponse: google.maps.places.AutocompleteResponse =
  158. await autoCompleteService.getPlacePredictions({
  159. input: e.target.value,
  160. // See https://developers.google.com/maps/documentation/javascript/supported_types
  161. types: ['address'],
  162. });
  163. if (autocompleteResponse?.predictions) {
  164. setPredictionData(autocompleteResponse.predictions);
  165. }
  166. if (!placeService) {
  167. setPlaceService(new google.maps.places.PlacesService(e.target));
  168. }
  169. } catch (exception) {
  170. Sentry.captureException(exception);
  171. }
  172. }
  173. // On selection, need to use PlaceService to fetch more data about the address (e.g. postal code)
  174. // that is not available in the prediction.
  175. function handleSelectEvent(item: PredictionItem, e: any) {
  176. if (e.key === 'Enter') {
  177. e.preventDefault();
  178. }
  179. if (!placeService) {
  180. return;
  181. }
  182. try {
  183. placeService.getDetails(
  184. {
  185. placeId: item.place_id ?? '',
  186. // See https://developers.google.com/maps/documentation/javascript/geocoding#GeocodingAddressTypes
  187. fields: ['address_components'],
  188. },
  189. (
  190. placeResult: google.maps.places.PlaceResult | null,
  191. placeServiceStatus: google.maps.places.PlacesServiceStatus
  192. ) => {
  193. if (
  194. placeServiceStatus === google.maps.places.PlacesServiceStatus.OK &&
  195. placeResult
  196. ) {
  197. const placeDetails = getPlaceDetailsItems(placeResult);
  198. form.setValue('addressLine1', placeDetails.addressLine1);
  199. form.setValue('city', placeDetails.city);
  200. form.setValue('region', placeDetails.regionCode);
  201. form.setValue('countryCode', placeDetails.countryCode);
  202. form.setValue('postalCode', placeDetails.postalCode);
  203. updateCountryCodeState(placeDetails.countryCode);
  204. }
  205. }
  206. );
  207. } catch (exception) {
  208. Sentry.captureException(exception);
  209. }
  210. }
  211. // See https://developers.google.com/maps/documentation/javascript/geocoding#GeocodingAddressTypes
  212. // for structure of PlaceResult and PlaceResult.address_components
  213. function getPlaceDetailsItems(placeResult: google.maps.places.PlaceResult) {
  214. const placeDetails = {
  215. addressLine1: '',
  216. city: '',
  217. country: '',
  218. countryCode: '',
  219. postalCode: '',
  220. region: '',
  221. regionCode: '',
  222. streetName: '',
  223. streetNumber: '',
  224. formattedAddress: '',
  225. };
  226. for (const addressComponent of placeResult.address_components ?? []) {
  227. const componentTypes = addressComponent.types;
  228. if (componentTypes.includes('street_number')) {
  229. placeDetails.streetNumber = addressComponent.long_name;
  230. } else if (componentTypes.includes('route')) {
  231. placeDetails.streetName = addressComponent.long_name;
  232. } else if (
  233. componentTypes.includes('postal_town') ||
  234. componentTypes.includes('locality')
  235. ) {
  236. placeDetails.city = addressComponent.long_name;
  237. } else if (componentTypes.includes('country')) {
  238. placeDetails.country = addressComponent.long_name;
  239. placeDetails.countryCode = addressComponent.short_name;
  240. } else if (componentTypes.includes('administrative_area_level_1')) {
  241. placeDetails.region = addressComponent.long_name;
  242. placeDetails.regionCode = addressComponent.short_name;
  243. } else if (componentTypes.includes('postal_code')) {
  244. placeDetails.postalCode = addressComponent.long_name;
  245. }
  246. }
  247. placeDetails.addressLine1 = [placeDetails.streetNumber, placeDetails.streetName]
  248. .filter(i => !!i)
  249. .join(' ');
  250. return placeDetails;
  251. }
  252. return (
  253. <Form
  254. apiMethod="PUT"
  255. model={form}
  256. requireChanges={requireChanges}
  257. apiEndpoint={`/customers/${organization.slug}/billing-details/`}
  258. submitLabel={submitLabel}
  259. onPreSubmit={onPreSubmit}
  260. onSubmitSuccess={onSubmitSuccess}
  261. onSubmitError={onSubmitError}
  262. initialData={transformedInitialData}
  263. footerStyle={footerStyle}
  264. >
  265. <FieldWrapper>
  266. {isDetailed && (
  267. <TextField
  268. name="billingEmail"
  269. label={t('Billing Email')}
  270. placeholder="billing@example.com"
  271. help={t(
  272. 'If provided, all receipts and billing-related notifications will be sent to this address.'
  273. )}
  274. {...fieldProps}
  275. />
  276. )}
  277. <TextField
  278. name="companyName"
  279. label={t('Company Name')}
  280. placeholder={t('Company name')}
  281. maxLength={100}
  282. {...fieldProps}
  283. />
  284. <AutoComplete
  285. onSelect={handleSelectEvent}
  286. itemToString={(selectItem?: PredictionItem) =>
  287. selectItem?.structured_formatting.main_text ?? ''
  288. }
  289. defaultInputValue={transformedInitialData.addressLine1 ?? ''}
  290. shouldSelectWithEnter
  291. >
  292. {({
  293. getInputProps,
  294. getItemProps,
  295. getMenuProps,
  296. isOpen,
  297. highlightedIndex,
  298. registerItemCount,
  299. registerVisibleItem,
  300. }) => (
  301. <FormField
  302. {...fieldProps}
  303. required
  304. name="addressLine1"
  305. label={t('Street Address 1')}
  306. help={
  307. isDetailed
  308. ? t("Your company's address of record will appear on all receipts.")
  309. : undefined
  310. }
  311. >
  312. {({onChange}: any) => {
  313. registerItemCount(predictionData.length);
  314. return (
  315. <Fragment>
  316. <Input
  317. id="addressLine1"
  318. required
  319. name="addressLine1"
  320. placeholder={t('Street address')}
  321. maxLength={100}
  322. {...getInputProps({
  323. onChange: e => {
  324. onChange(e); // call the default onChange to set the FormModel with the value of addressLine1
  325. handleAddressChange(e);
  326. },
  327. })}
  328. disabled={!organization.access.includes('org:billing')}
  329. />
  330. {predictionData.length > 0 && isOpen && (
  331. <StyledDropdownBubble
  332. alignMenu="left"
  333. blendCorner={false}
  334. {...getMenuProps()}
  335. >
  336. {predictionData.map((item, index) => (
  337. <AddressItem
  338. item={item}
  339. index={index}
  340. key={index}
  341. priority={index === highlightedIndex ? 'primary' : 'default'}
  342. registerVisibleItem={registerVisibleItem}
  343. {...getItemProps({index, item})}
  344. />
  345. ))}
  346. </StyledDropdownBubble>
  347. )}
  348. </Fragment>
  349. );
  350. }}
  351. </FormField>
  352. )}
  353. </AutoComplete>
  354. <TextField
  355. name="addressLine2"
  356. label={t('Street Address 2')}
  357. placeholder={t('Unit, building, floor, etc.')}
  358. maxLength={100}
  359. {...fieldProps}
  360. />
  361. <SelectField
  362. required
  363. allowClear
  364. name="countryCode"
  365. label={t('Country')}
  366. placeholder={t('Country')}
  367. choices={COUNTRY_CODE_CHOICES}
  368. onChange={updateCountryCodeState}
  369. {...fieldProps}
  370. />
  371. <TextField
  372. required
  373. name="city"
  374. label={t('City')}
  375. placeholder={t('City')}
  376. maxLength={100}
  377. {...fieldProps}
  378. />
  379. {regionChoices.length ? (
  380. <SelectField
  381. required
  382. allowClear
  383. name="region"
  384. label={t('State / Region')}
  385. placeholder={t('State or region')}
  386. choices={regionChoices}
  387. {...fieldProps}
  388. />
  389. ) : (
  390. <TextField
  391. required
  392. name="region"
  393. label={t('State / Region')}
  394. placeholder={t('State or region')}
  395. maxLength={100}
  396. {...fieldProps}
  397. />
  398. )}
  399. <TextField
  400. required
  401. name="postalCode"
  402. label={t('Postal Code')}
  403. placeholder={t('Postal code')}
  404. maxLength={12}
  405. {...fieldProps}
  406. />
  407. {!!(state.showTaxNumber && taxFieldInfo) && (
  408. <TextField
  409. name="taxNumber"
  410. label={taxFieldInfo.label}
  411. placeholder={taxFieldInfo.placeholder}
  412. help={tct(
  413. "Your company's [taxNumberName] will appear on all receipts. You may be subject to taxes depending on country specific tax policies.",
  414. {taxNumberName: <strong>{taxFieldInfo.taxNumberName}</strong>}
  415. )}
  416. maxLength={25}
  417. {...fieldProps}
  418. />
  419. )}
  420. </FieldWrapper>
  421. </Form>
  422. );
  423. }
  424. type AddressItemProps = React.ComponentProps<typeof MenuListItem> & {
  425. index: number;
  426. item: PredictionItem;
  427. registerVisibleItem: (index: number, item: PredictionItem) => void;
  428. };
  429. function AddressItem({registerVisibleItem, index, item, ...props}: AddressItemProps) {
  430. useEffect(() => {
  431. registerVisibleItem(index, item);
  432. }, [item, index, registerVisibleItem]);
  433. return <MenuListItem label={item.description} {...props} />;
  434. }
  435. const StyledDropdownBubble = styled(DropdownBubble)`
  436. margin-top: ${space(1)};
  437. `;
  438. export default BillingDetailsForm;