diff --git a/android/app/build.gradle b/android/app/build.gradle index 29967f558023..870956678681 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -148,8 +148,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001001304 - versionName "1.0.13-4" + versionCode 1001001600 + versionName "1.0.16-0" } splits { abi { diff --git a/ios/ExpensifyCash/Info.plist b/ios/ExpensifyCash/Info.plist index 29e91d8628f2..6cd3c2c6c0ff 100644 --- a/ios/ExpensifyCash/Info.plist +++ b/ios/ExpensifyCash/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.13 + 1.0.16 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.0.13.4 + 1.0.16.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/ExpensifyCashTests/Info.plist b/ios/ExpensifyCashTests/Info.plist index 07039c4480c1..0400306189c4 100644 --- a/ios/ExpensifyCashTests/Info.plist +++ b/ios/ExpensifyCashTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.0.13 + 1.0.16 CFBundleSignature ???? CFBundleVersion - 1.0.13.4 + 1.0.16.0 diff --git a/package-lock.json b/package-lock.json index 0d847704a24a..2da37138c74d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.13-4", + "version": "1.0.16-0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1444,6 +1444,87 @@ } } }, + "@formatjs/ecma402-abstract": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.6.4.tgz", + "integrity": "sha512-ukFjGD9dLsxcD9D5AEshJqQElPQeUAlTALT/lzIV6OcYojyuU81gw/uXDUOrs6XW79jtOJwQDkLqHbCJBJMOTw==", + "requires": { + "tslib": "^2.1.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, + "@formatjs/intl-getcanonicallocales": { + "version": "1.5.8", + "resolved": "https://registry.npmjs.org/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-1.5.8.tgz", + "integrity": "sha512-6GEIfCsZ+wd/K8bixP5h0Ep5aOjMgHlM51TeznlcNoiOHPP4gOrkxggh2Y2G5lnk71Ocyi93/+d0oKJI3J0jzw==", + "requires": { + "cldr-core": "38", + "tslib": "^2.1.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, + "@formatjs/intl-locale": { + "version": "2.4.21", + "resolved": "https://registry.npmjs.org/@formatjs/intl-locale/-/intl-locale-2.4.21.tgz", + "integrity": "sha512-AH7d6XaLq1pXZ/AQ4dRNveKmA0juCCN3hFdpBvVA3XT4EMXIVkERh8PSa7xKgZThgXJwSLCZgKAeaARDzmhFRA==", + "requires": { + "@formatjs/ecma402-abstract": "1.6.4", + "@formatjs/intl-getcanonicallocales": "1.5.8", + "cldr-core": "38", + "tslib": "^2.1.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, + "@formatjs/intl-numberformat": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@formatjs/intl-numberformat/-/intl-numberformat-6.2.5.tgz", + "integrity": "sha512-OnumcFnxnrRhfcL/KMBmC54i948YUQ3eh+J4DiHjs1QRx8iattj/07UEipqMvu6iHjnqU0PRTxFBGG2L+9JWXw==", + "requires": { + "@formatjs/ecma402-abstract": "1.6.4", + "tslib": "^2.1.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, + "@formatjs/intl-pluralrules": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/@formatjs/intl-pluralrules/-/intl-pluralrules-4.0.13.tgz", + "integrity": "sha512-ePoC1zmSzvyxXnrhPkysAQMIWr1JO5Hbz8yRv9ARgz6rD68k+wfD743AiHY/yjlahnXaqHDTd7e07xwrbzAsgQ==", + "requires": { + "@formatjs/ecma402-abstract": "1.6.4", + "tslib": "^2.1.0" + }, + "dependencies": { + "tslib": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + } + } + }, "@hapi/address": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.4.tgz", @@ -7027,6 +7108,11 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", "integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0=" }, + "cldr-core": { + "version": "38.1.0", + "resolved": "https://registry.npmjs.org/cldr-core/-/cldr-core-38.1.0.tgz", + "integrity": "sha512-Da9xKjDp4qGGIX0VDsBqTan09iR5nuYD2a/KkfEaUyqKhu6wFVNRiCpPDXeRbpVwPBY6PgemV8WiHatMhcpy4A==" + }, "clean-css": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.3.tgz", @@ -11120,8 +11206,8 @@ } }, "expensify-common": { - "version": "git+https://github.com/Expensify/expensify-common.git#3d8fc7500ddd24cd4a543e6e160d4f1ad97cc145", - "from": "git+https://github.com/Expensify/expensify-common.git#3d8fc7500ddd24cd4a543e6e160d4f1ad97cc145", + "version": "git+https://github.com/Expensify/expensify-common.git#679fc86cfc4f9bc701e3757d583d74057edbbe28", + "from": "git+https://github.com/Expensify/expensify-common.git#679fc86cfc4f9bc701e3757d583d74057edbbe28", "requires": { "classnames": "2.2.5", "clipboard": "2.0.4", @@ -21241,6 +21327,53 @@ "lodash": "4.17.21", "react": "^16.13.1", "underscore": "^1.11.0" + }, + "dependencies": { + "expensify-common": { + "version": "git+https://github.com/Expensify/expensify-common.git#3d8fc7500ddd24cd4a543e6e160d4f1ad97cc145", + "from": "git+https://github.com/Expensify/expensify-common.git#3d8fc7500ddd24cd4a543e6e160d4f1ad97cc145", + "requires": { + "classnames": "2.2.5", + "clipboard": "2.0.4", + "html-entities": "^1.3.1", + "jquery": "3.3.1", + "lodash": "4.17.21", + "prop-types": "15.7.2", + "react": "16.12.0", + "react-dom": "16.12.0", + "semver": "^7.3.4", + "simply-deferred": "git+https://github.com/Expensify/simply-deferred.git#77a08a95754660c7bd6e0b6979fdf84e8e831bf5", + "underscore": "1.9.1" + }, + "dependencies": { + "react": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz", + "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2" + } + }, + "underscore": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz", + "integrity": "sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==" + } + } + }, + "react-dom": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.12.0.tgz", + "integrity": "sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.18.0" + } + } } }, "react-native-pdf": { diff --git a/package.json b/package.json index 099ccd058422..59dbc3d85ad1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.13-4", + "version": "1.0.16-0", "author": "Expensify, Inc.", "homepage": "https://expensify.cash", "description": "Expensify.cash is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -31,6 +31,10 @@ "dependencies": { "@babel/plugin-proposal-class-properties": "^7.12.1", "@babel/preset-flow": "^7.12.13", + "@formatjs/intl-getcanonicallocales": "^1.5.8", + "@formatjs/intl-locale": "^2.4.21", + "@formatjs/intl-numberformat": "^6.2.5", + "@formatjs/intl-pluralrules": "^4.0.13", "@react-native-community/async-storage": "^1.11.0", "@react-native-community/cli": "4.13.1", "@react-native-community/clipboard": "^1.5.1", @@ -51,7 +55,7 @@ "electron-log": "^4.2.4", "electron-serve": "^1.0.0", "electron-updater": "^4.3.4", - "expensify-common": "git+https://github.com/Expensify/expensify-common.git#3d8fc7500ddd24cd4a543e6e160d4f1ad97cc145", + "expensify-common": "git+https://github.com/Expensify/expensify-common.git#679fc86cfc4f9bc701e3757d583d74057edbbe28", "file-loader": "^6.0.0", "html-entities": "^1.3.1", "lodash": "4.17.21", diff --git a/src/ROUTES.js b/src/ROUTES.js index b447820eb929..2e706584f08f 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -19,7 +19,7 @@ export default { IOU_BILL: 'iou/split', SEARCH: 'search', SIGNIN: 'signin', - SET_PASSWORD_WITH_VALIDATE_CODE: 'setpassword/:validateCode', + SET_PASSWORD_WITH_VALIDATE_CODE: 'setpassword/:accountID/:validateCode', DETAILS: 'details', DETAILS_WITH_LOGIN: 'details/:login', getDetailsRoute: login => `details/${login}`, diff --git a/src/components/FAB.js b/src/components/FAB.js index 6986c1144500..792560b903fe 100644 --- a/src/components/FAB.js +++ b/src/components/FAB.js @@ -1,7 +1,5 @@ import React, {PureComponent} from 'react'; -import { - Pressable, Animated, Easing, KeyboardAvoidingView, Platform, -} from 'react-native'; +import {Pressable, Animated, Easing} from 'react-native'; import PropTypes from 'prop-types'; import Icon from './Icon'; import {Plus} from './Icon/Expensicons'; @@ -66,17 +64,15 @@ class FAB extends PureComponent { }); return ( - - - - - + + + ); } } diff --git a/src/components/withLocalize.js b/src/components/withLocalize.js new file mode 100644 index 000000000000..b18c99b37a39 --- /dev/null +++ b/src/components/withLocalize.js @@ -0,0 +1,63 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; +import compose from '../libs/compose'; +import ONYXKEYS from '../ONYXKEYS'; +import {translate} from '../libs/translate'; +import DateUtils from '../libs/DateUtils'; +import {toLocalPhone, fromLocalPhone} from '../libs/LocalePhoneNumber'; +import numberFormat from '../libs/numberFormat'; + +const withLocalizePropTypes = { + // Translations functions using current User's preferred locale + translations: PropTypes.shape({ + translate: PropTypes.func.isRequired, + numberFormat: PropTypes.func.isRequired, + timestampToRelative: PropTypes.func.isRequired, + timestampToDateTime: PropTypes.func.isRequired, + toLocalPhone: PropTypes.func.isRequired, + fromLocalPhone: PropTypes.func.isRequired, + }), +}; + +function withLocalizeHOC(WrappedComponent) { + const withLocalize = (props) => { + const translations = { + translate: (phrase, variables) => translate(props.preferredLocale, phrase, variables), + numberFormat: (number, options) => numberFormat(props.preferredLocale, number, options), + timestampToRelative: timestamp => DateUtils.timestampToRelative(props.preferredLocale, timestamp), + timestampToDateTime: (timestamp, includeTimezone) => DateUtils.timestampToDateTime( + props.preferredLocale, + timestamp, + includeTimezone, + ), + toLocalPhone: number => toLocalPhone(props.preferredLocale, number), + fromLocalPhone: number => fromLocalPhone(props.preferredLocale, number), + }; + return ( + + ); + }; + withLocalize.displayName = `withLocalize(${getComponentDisplayName(WrappedComponent)})`; + withLocalize.defaultProps = { + preferredLocale: 'en', + }; + return withLocalize; +} +export default compose( + withOnyx({ + preferredLocale: { + key: ONYXKEYS.PREFERRED_LOCALE, + }, + }), + withLocalizeHOC, +); + +export { + withLocalizePropTypes, +}; diff --git a/src/libs/API.js b/src/libs/API.js index fefaab656d21..4b0245851270 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -554,11 +554,12 @@ function ResetPassword(parameters) { * @param {Object} parameters * @param {String} parameters.password * @param {String} parameters.validateCode + * @param {String} parameters.accountID * @returns {Promise} */ function SetPassword(parameters) { const commandName = 'SetPassword'; - requireParameters(['email', 'password', 'validateCode'], parameters, commandName); + requireParameters(['accountID', 'password', 'validateCode'], parameters, commandName); return Network.post(commandName, parameters); } diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index a68d44e729c0..7a677d5cf01d 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -110,10 +110,13 @@ function fetch() { .then((data) => { let myPersonalDetails = {}; - // If personalDetailsList is empty, ensure we set the personal details for the current user - const personalDetailsList = _.isEmpty(data.personalDetailsList) - ? {[currentUserEmail]: myPersonalDetails} - : data.personalDetailsList; + // If personalDetailsList does not have the current user ensure we initialize their details with an empty + // object at least + const personalDetailsList = _.isEmpty(data.personalDetailsList) ? {} : data.personalDetailsList; + if (!personalDetailsList[currentUserEmail]) { + personalDetailsList[currentUserEmail] = {}; + } + const allPersonalDetails = formatPersonalDetails(personalDetailsList); Onyx.merge(ONYXKEYS.PERSONAL_DETAILS, allPersonalDetails); diff --git a/src/libs/actions/Session.js b/src/libs/actions/Session.js index 759a7ba6c385..3cfa6a1c0774 100644 --- a/src/libs/actions/Session.js +++ b/src/libs/actions/Session.js @@ -221,19 +221,19 @@ function restartSignin() { * * @param {String} password * @param {String} validateCode + * @param {String} accountID */ -function setPassword(password, validateCode) { +function setPassword(password, validateCode, accountID) { Onyx.merge(ONYXKEYS.ACCOUNT, {error: '', loading: true}); API.SetPassword({ - email: credentials.login, password, validateCode, + accountID, }) .then((response) => { if (response.jsonCode === 200) { - const {authToken, email} = response; - createTemporaryLogin(authToken, email); + createTemporaryLogin(response.authToken, response.email); return; } diff --git a/src/libs/numberFormat/index.android.js b/src/libs/numberFormat/index.android.js new file mode 100644 index 000000000000..de5366f4faba --- /dev/null +++ b/src/libs/numberFormat/index.android.js @@ -0,0 +1,14 @@ +// we only need polyfills for Mobile. +import '@formatjs/intl-getcanonicallocales/polyfill'; +import '@formatjs/intl-locale/polyfill'; +import '@formatjs/intl-pluralrules/polyfill'; +import '@formatjs/intl-numberformat/polyfill'; + +// Load en Locale data +import '@formatjs/intl-numberformat/locale-data/en'; + +function numberFormat(locale, number, options) { + return new Intl.NumberFormat(locale, options).format(number); +} + +export default numberFormat; diff --git a/src/libs/numberFormat/index.js b/src/libs/numberFormat/index.js new file mode 100644 index 000000000000..d956a5499265 --- /dev/null +++ b/src/libs/numberFormat/index.js @@ -0,0 +1,5 @@ +function numberFormat(locale, number, options) { + return new Intl.NumberFormat(locale, options).format(number); +} + +export default numberFormat; diff --git a/src/pages/SetPasswordPage.js b/src/pages/SetPasswordPage.js index 79679dca6141..75112a8de3db 100644 --- a/src/pages/SetPasswordPage.js +++ b/src/pages/SetPasswordPage.js @@ -37,8 +37,14 @@ const propTypes = { password: PropTypes.string, }), + // The accountID and validateCode are passed via the URL route: PropTypes.shape({ + // Each parameter passed via the URL params: PropTypes.shape({ + // The user's accountID + accountID: PropTypes.string, + + // The user's validateCode validateCode: PropTypes.string, }), }), @@ -78,7 +84,11 @@ class SetPasswordPage extends Component { this.setState({ formError: null, }); - setPassword(this.state.password, lodashGet(this.props.route, 'params.validateCode', '')); + setPassword( + this.state.password, + lodashGet(this.props.route, 'params.validateCode', ''), + lodashGet(this.props.route, 'params.accountID', ''), + ); } render() { diff --git a/src/pages/settings/AddSecondaryLoginPage.js b/src/pages/settings/AddSecondaryLoginPage.js index 24b339414da1..ed5fa77ec2d4 100644 --- a/src/pages/settings/AddSecondaryLoginPage.js +++ b/src/pages/settings/AddSecondaryLoginPage.js @@ -41,7 +41,9 @@ const propTypes = { // Route object from navigation route: PropTypes.shape({ + // Params that are passed into the route params: PropTypes.shape({ + // The type of secondary login to be added (email|phone) type: PropTypes.string, }), }), @@ -69,6 +71,9 @@ class AddSecondaryLoginPage extends Component { Onyx.merge(ONYXKEYS.USER, {error: ''}); } + /** + * Add a secondary login to a user's account + */ submitForm() { setSecondaryLogin(this.state.login, this.state.password) .then((response) => { @@ -78,7 +83,11 @@ class AddSecondaryLoginPage extends Component { }); } - // Determines whether form is valid + /** + * Determine whether the form is valid + * + * @returns {Boolean} + */ validateForm() { const validationMethod = this.formType === CONST.LOGIN_TYPE.PHONE ? Str.isValidPhone : Str.isValidEmail; return !this.state.password || !validationMethod(this.state.login); diff --git a/src/pages/settings/PreferencesPage.js b/src/pages/settings/PreferencesPage.js index 2837ecc02942..89311c149e09 100644 --- a/src/pages/settings/PreferencesPage.js +++ b/src/pages/settings/PreferencesPage.js @@ -42,8 +42,8 @@ const priorityModes = { }, gsd: { value: CONST.PRIORITY_MODE.GSD, - label: 'GSD', - description: 'This will only display unread and pinned chats, all sorted alphabetically. Get Shit Done.', + label: '#focus', + description: '#focus – This will only display unread and pinned chats, all sorted alphabetically.', }, }; diff --git a/src/pages/settings/ProfilePage/LoginField.js b/src/pages/settings/ProfilePage/LoginField.js index 95d63c632835..9342f4e7b059 100644 --- a/src/pages/settings/ProfilePage/LoginField.js +++ b/src/pages/settings/ProfilePage/LoginField.js @@ -20,7 +20,10 @@ const propTypes = { // Login associated with the user login: PropTypes.shape({ + // Phone/Email associated with user partnerUserID: PropTypes.string, + + // Date of when login was validated validatedDate: PropTypes.string, }).isRequired, }; @@ -35,6 +38,9 @@ export default class LoginField extends Component { this.onResendClicked = this.onResendClicked.bind(this); } + /** + * Resend validation code and show the checkmark icon + */ onResendClicked() { resendValidateCode(this.props.login.partnerUserID); this.setState({showCheckmarkIcon: true}); diff --git a/src/pages/settings/ProfilePage/index.js b/src/pages/settings/ProfilePage/index.js index 3eea6852c437..d511b8a22ab3 100644 --- a/src/pages/settings/ProfilePage/index.js +++ b/src/pages/settings/ProfilePage/index.js @@ -77,7 +77,9 @@ const propTypes = { const defaultProps = { myPersonalDetails: {}, - user: {}, + user: { + loginList: [], + }, }; const timezones = moment.tz.names() @@ -134,6 +136,11 @@ class ProfilePage extends Component { } } + /** + * Set the form to use automatic timezone + * + * @param {Boolean} isAutomaticTimezone + */ setAutomaticTimezone(isAutomaticTimezone) { this.setState(({selectedTimezone}) => ({ isAutomaticTimezone, @@ -141,7 +148,12 @@ class ProfilePage extends Component { })); } - // Get the most validated login of each type + /** + * Get the most validated login of each type + * + * @param {Array} loginList + * @returns {Object} + */ getLogins(loginList) { return loginList.reduce((logins, currentLogin) => { const type = Str.isSMSLogin(currentLogin.partnerUserID) ? CONST.LOGIN_TYPE.PHONE : CONST.LOGIN_TYPE.EMAIL; @@ -166,6 +178,9 @@ class ProfilePage extends Component { }); } + /** + * Submit form to update personal details + */ updatePersonalDetails() { const { firstName, @@ -187,6 +202,12 @@ class ProfilePage extends Component { }); } + /** + * Create menu items list for avatar menu + * + * @param {Function} openPicker + * @returns {Array} + */ createMenuItems(openPicker) { const menuItems = [ { diff --git a/src/styles/styles.js b/src/styles/styles.js index e2ea0487940e..6e7fd6395c03 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -28,6 +28,7 @@ const styles = { link: { color: themeColors.link, textDecorationColor: themeColors.link, + fontFamily: fontFamily.GTA, }, h1: {