From 6832512076adbd2fd2086c20dd34adce7b2d3feb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 01:50:28 +0800 Subject: [PATCH] feat(account): phone sign-in, code verification and the account screen - Sign in and sign up share one phone-first screen: searchable country picker, inline errors, enabled social providers, terms and privacy links. - One code screen handles sign-in, sign-up, adding a phone and confirming account deletion: six boxes with SMS autofill, auto-submit, inline mismatch error and a resend timer. - Signing in from checkout returns straight to the checkout. - New account screen: profile header, add-phone prompt, active order, orders, offers, saved places, payment methods, notifications with an unread badge, language and appearance pickers, sign out and delete. - Remove the screens these replace. - Web: shim the native Google Sign-In package, which crashed the web bundle, and hide the Google button there. --- __tests__/navigation/auth-redirect.test.ts | 24 ++ __tests__/web-shims.test.js | 12 + package.json | 2 +- src/components/auth/PhoneField.tsx | 121 ++++++++ src/components/checkout/CheckoutLayout.tsx | 101 +++++- src/extensions/screens/default-screens.ts | 6 +- src/hooks/use-oauth.web.ts | 3 +- src/navigation/StoreNavigator.tsx | 15 +- src/navigation/auth-redirect.ts | 19 ++ src/screens/AddPhoneScreen.tsx | 80 ----- src/screens/CreateAccountScreen.tsx | 91 ------ src/screens/CreateAccountVerifyScreen.tsx | 71 ----- src/screens/DeleteAccountVerifyScreen.tsx | 83 ----- src/screens/LoginScreen.tsx | 90 ------ src/screens/PhoneLoginScreen.tsx | 91 ------ src/screens/PhoneLoginVerifyScreen.tsx | 77 ----- src/screens/ProfileScreen.tsx | 139 --------- src/screens/VerifyPhoneScreen.tsx | 82 ----- src/screens/account/AccountHomeScreen.tsx | 343 +++++++++++++++++++++ src/screens/auth/AddPhoneScreen.tsx | 7 + src/screens/auth/CreateAccountScreen.tsx | 7 + src/screens/auth/SignInScreen.tsx | 199 ++++++++++++ src/screens/auth/VerifyCodeScreen.tsx | 187 +++++++++++ src/ui/CodeInput.tsx | 61 ++++ src/ui/index.ts | 1 + translations/en.json | 83 +++++ translations/mn.json | 83 +++++ translations/uk.json | 83 +++++ web/react-native-google-signin.web.js | 18 ++ webpack.config.js | 1 + 30 files changed, 1351 insertions(+), 829 deletions(-) create mode 100644 __tests__/navigation/auth-redirect.test.ts create mode 100644 src/components/auth/PhoneField.tsx create mode 100644 src/navigation/auth-redirect.ts delete mode 100644 src/screens/AddPhoneScreen.tsx delete mode 100644 src/screens/CreateAccountScreen.tsx delete mode 100644 src/screens/CreateAccountVerifyScreen.tsx delete mode 100644 src/screens/DeleteAccountVerifyScreen.tsx delete mode 100644 src/screens/LoginScreen.tsx delete mode 100644 src/screens/PhoneLoginScreen.tsx delete mode 100644 src/screens/PhoneLoginVerifyScreen.tsx delete mode 100644 src/screens/ProfileScreen.tsx delete mode 100644 src/screens/VerifyPhoneScreen.tsx create mode 100644 src/screens/account/AccountHomeScreen.tsx create mode 100644 src/screens/auth/AddPhoneScreen.tsx create mode 100644 src/screens/auth/CreateAccountScreen.tsx create mode 100644 src/screens/auth/SignInScreen.tsx create mode 100644 src/screens/auth/VerifyCodeScreen.tsx create mode 100644 src/ui/CodeInput.tsx create mode 100644 web/react-native-google-signin.web.js diff --git a/__tests__/navigation/auth-redirect.test.ts b/__tests__/navigation/auth-redirect.test.ts new file mode 100644 index 00000000..6b2fc751 --- /dev/null +++ b/__tests__/navigation/auth-redirect.test.ts @@ -0,0 +1,24 @@ +import { setAuthRedirect, takeAuthRedirect } from '../../src/navigation/auth-redirect'; + +describe('auth redirect', () => { + afterEach(() => setAuthRedirect(null)); + + it('hands the destination over once', () => { + setAuthRedirect({ route: 'StoreCartTab', params: { screen: 'Checkout' } }); + expect(takeAuthRedirect()).toEqual({ route: 'StoreCartTab', params: { screen: 'Checkout' } }); + expect(takeAuthRedirect()).toBeNull(); + }); + + it('is empty until one is set, and a later one replaces it', () => { + expect(takeAuthRedirect()).toBeNull(); + setAuthRedirect({ route: 'A' }); + setAuthRedirect({ route: 'B' }); + expect(takeAuthRedirect()).toEqual({ route: 'B' }); + }); + + it('can be cleared', () => { + setAuthRedirect({ route: 'A' }); + setAuthRedirect(null); + expect(takeAuthRedirect()).toBeNull(); + }); +}); diff --git a/__tests__/web-shims.test.js b/__tests__/web-shims.test.js index 2cf6d2d4..f7a405e2 100644 --- a/__tests__/web-shims.test.js +++ b/__tests__/web-shims.test.js @@ -122,3 +122,15 @@ describe('web device info shim', () => { await expect(deviceInfo.getUniqueId()).resolves.toBe('existing-id'); }); }); + +describe('google sign-in web shim', () => { + const { GoogleSignin, statusCodes } = require('../web/react-native-google-signin.web'); + + it('configures without doing anything and refuses to sign in', async () => { + expect(() => GoogleSignin.configure({ webClientId: 'x' })).not.toThrow(); + await expect(GoogleSignin.hasPlayServices()).resolves.toBe(false); + await expect(GoogleSignin.signIn()).rejects.toThrow('not available on the web'); + expect(GoogleSignin.getCurrentUser()).toBeNull(); + expect(statusCodes.SIGN_IN_CANCELLED).toBe('SIGN_IN_CANCELLED'); + }); +}); diff --git a/package.json b/package.json index 0a6e5939..45510621 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "jest", "test:ci": "jest --runInBand", "lint": "eslint .", - "lint:strict": "eslint src/ui storefront.extensions.ts storefront.brand.ts src/env.ts src/branding src/hooks/use-app-theme.ts src/contexts/ThemeContext.tsx src/extensions src/navigation/NetworkNavigator.tsx src/navigation/AppNavigator.tsx src/network src/screens/network src/navigation/NetworkNavigator.tsx src/contexts/StorefrontRuntimeContext.tsx src/contexts/CartContext.tsx src/screens/BootScreen.tsx src/screens/LocationPermissionScreen.tsx src/commerce src/components/checkout src/components/booking src/hooks/use-cart-promotions.ts src/hooks/use-review-request.ts src/screens/reviews src/screens/offers src/components/offers src/screens/notifications src/screens/chat src/hooks/use-chat-request.ts src/hooks/use-customer-request.ts src/hooks/use-open-notification.ts src/hooks/use-unread-notifications.ts src/hooks/use-footer-offset.ts src/screens/CartScreen.tsx src/screens/OrderScreen.tsx src/screens/StripeCheckoutScreen.tsx src/screens/QPayCheckoutScreen.tsx web/react-native-mmkv-storage.js web/react-native-device-info.web.js __tests__", + "lint:strict": "eslint src/ui storefront.extensions.ts storefront.brand.ts src/env.ts src/branding src/hooks/use-app-theme.ts src/contexts/ThemeContext.tsx src/extensions src/navigation/NetworkNavigator.tsx src/navigation/AppNavigator.tsx src/network src/screens/network src/navigation/NetworkNavigator.tsx src/contexts/StorefrontRuntimeContext.tsx src/contexts/CartContext.tsx src/screens/BootScreen.tsx src/screens/LocationPermissionScreen.tsx src/commerce src/components/checkout src/components/booking src/hooks/use-cart-promotions.ts src/hooks/use-review-request.ts src/screens/reviews src/screens/offers src/components/offers src/screens/notifications src/screens/chat src/hooks/use-chat-request.ts src/hooks/use-customer-request.ts src/hooks/use-open-notification.ts src/hooks/use-unread-notifications.ts src/hooks/use-footer-offset.ts src/screens/CartScreen.tsx src/screens/OrderScreen.tsx src/screens/StripeCheckoutScreen.tsx src/screens/QPayCheckoutScreen.tsx web/react-native-mmkv-storage.js web/react-native-device-info.web.js __tests__ src/screens/auth src/screens/account src/components/auth src/navigation/auth-redirect.ts", "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json", "format": "prettier --write .", "tamagui:build": "./node_modules/.bin/tamagui-build", diff --git a/src/components/auth/PhoneField.tsx b/src/components/auth/PhoneField.tsx new file mode 100644 index 00000000..fd0e08a1 --- /dev/null +++ b/src/components/auth/PhoneField.tsx @@ -0,0 +1,121 @@ +import React, { useMemo, useState } from 'react'; +import { FlatList, Pressable, TextInput } from 'react-native'; +import { countries, getEmojiFlag } from 'countries-list'; +import { getLocales } from 'react-native-localize'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import { XStack, YStack, useTheme } from 'tamagui'; +import { useLanguage } from '../../contexts/LanguageContext'; +import { storefrontConfig } from '../../utils'; +import { Sheet, TextField, UIText, radius } from '../../ui'; + +type Country = { code: string; name: string; dial: string; flag: string }; + +const COUNTRIES: Country[] = Object.entries(countries) + .map(([code, details]: [string, any]) => ({ code, name: details.name, dial: String(details.phone?.[0] ?? ''), flag: getEmojiFlag(code as any) })) + .filter((country) => country.dial) + .sort((a, b) => a.name.localeCompare(b.name)); + +function defaultCountry(): Country { + const configured = storefrontConfig('defaultCountry'); + const fromDevice = getLocales()?.[0]?.countryCode; + return COUNTRIES.find((country) => country.code === configured) ?? COUNTRIES.find((country) => country.code === fromDevice) ?? COUNTRIES.find((country) => country.code === 'US')!; +} + +/** Splits "+6591234567" into its country and local number, best match first. */ +function split(value: string | null | undefined): { country: Country; number: string } { + const digits = (value ?? '').replace(/[^\d+]/g, ''); + if (digits.startsWith('+')) { + const match = [...COUNTRIES].sort((a, b) => b.dial.length - a.dial.length).find((country) => digits.slice(1).startsWith(country.dial)); + if (match) return { country: match, number: digits.slice(1 + match.dial.length) }; + } + return { country: defaultCountry(), number: digits.replace('+', '') }; +} + +/** + * A mobile number: a country button (flag and dial code, searchable list) and the local + * number. Reports the full number in E.164 form ("+6591234567"). + */ +export default function PhoneField({ value, onChange, onSubmit, invalid = false, autoFocus = false }: { value: string; onChange: (phone: string) => void; onSubmit?: () => void; invalid?: boolean; autoFocus?: boolean }) { + const theme = useTheme(); + const { t } = useLanguage(); + const initial = useMemo(() => split(value), []); // eslint-disable-line react-hooks/exhaustive-deps + const [country, setCountry] = useState(initial.country); + const [number, setNumber] = useState(initial.number); + const [picking, setPicking] = useState(false); + const [search, setSearch] = useState(''); + const [focused, setFocused] = useState(false); + + const report = (nextCountry: Country, nextNumber: string) => onChange(nextNumber ? `+${nextCountry.dial}${nextNumber.replace(/^0+/, '')}` : ''); + const matches = search.trim() ? COUNTRIES.filter((entry) => `${entry.name} ${entry.code} +${entry.dial}`.toLowerCase().includes(search.trim().toLowerCase())) : COUNTRIES; + + return ( + <> + + setPicking(true)} + accessibilityRole='button' + accessibilityLabel={t('Auth.countryCode', { country: country.name, code: country.dial })} + style={{ height: 54, paddingHorizontal: 12, borderRadius: radius.button, borderWidth: 1, borderColor: theme.borderColor.val, flexDirection: 'row', alignItems: 'center', gap: 6 }} + > + + {country.flag} +{country.dial} + + + + { + const digits = text.replace(/\D/g, '').slice(0, 15); + setNumber(digits); + report(country, digits); + }} + keyboardType='phone-pad' + textContentType='telephoneNumber' + autoComplete='tel' + autoFocus={autoFocus} + returnKeyType='done' + onSubmitEditing={onSubmit} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + accessibilityLabel={t('Auth.mobileNumber')} + placeholder={t('Auth.phonePlaceholder')} + placeholderTextColor={theme.textPlaceholder.val} + style={{ flex: 1, height: 54, paddingHorizontal: 14, borderRadius: radius.button, borderWidth: 2, borderColor: invalid ? theme.errorForeground.val : focused ? theme.primary.val : theme.borderColor.val, color: theme.textPrimary.val, fontSize: 17, fontWeight: '600', letterSpacing: 0.3 }} + /> + + + setPicking(false)} title={t('Auth.chooseCountry')} maxHeightRatio={0.85}> + + + item.code} + keyboardShouldPersistTaps='handled' + style={{ maxHeight: 420 }} + initialNumToRender={20} + renderItem={({ item }) => ( + { + setCountry(item); + report(item, number); + setPicking(false); + setSearch(''); + }} + accessibilityRole='radio' + accessibilityState={{ checked: item.code === country.code }} + style={{ minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: 12, borderBottomWidth: 1, borderColor: theme.borderColor.val }} + > + {item.flag} + + {item.name} + + +{item.dial} + + )} + /> + + + + ); +} diff --git a/src/components/checkout/CheckoutLayout.tsx b/src/components/checkout/CheckoutLayout.tsx index 3173645a..2e945e7e 100644 --- a/src/components/checkout/CheckoutLayout.tsx +++ b/src/components/checkout/CheckoutLayout.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from 'react'; import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { faCalendarDays, faChevronLeft, faCircleExclamation, faLocationDot, faLock } from '@fortawesome/free-solid-svg-icons'; @@ -12,7 +12,23 @@ import useStorefrontInfo from '../../hooks/use-storefront-info'; import useSavedLocations from '../../hooks/use-saved-locations'; import useCartPromotions from '../../hooks/use-cart-promotions'; import { formatCurrency } from '../../utils/format'; -import { Button, Card, IconButton, LocationSheet, SegmentedControl, Skeleton, StoreLogo, TextField, UIText, cartGroups, elevation, formatClock, radius, space, usesTwelveHourClock } from '../../ui'; +import { + Button, + Card, + IconButton, + LocationSheet, + SegmentedControl, + Skeleton, + StoreLogo, + TextField, + UIText, + cartGroups, + elevation, + formatClock, + radius, + space, + usesTwelveHourClock, +} from '../../ui'; import TipSelector from './TipSelector'; import { parseScheduledAt } from '../../commerce/booking'; @@ -60,6 +76,7 @@ export type CheckoutLayoutProps = { */ export default function CheckoutLayout({ checkout, payment, extra, paymentReady, onPlaceOrder, footerOffset, children }: CheckoutLayoutProps) { const navigation = useNavigation(); + const route = useRoute(); const insets = useSafeAreaInsets(); const theme = useTheme(); const { t, locale } = useLanguage(); @@ -87,7 +104,15 @@ export default function CheckoutLayout({ checkout, payment, extra, paymentReady, const location = checkout.deliveryLocation; const placeAttr = (key: string) => (typeof location?.getAttribute === 'function' ? location.getAttribute(key) : location?.[key]); const quoteAmount = checkout.serviceQuote ? Number(checkout.serviceQuote.getAttribute?.('amount') ?? checkout.serviceQuote.amount ?? 0) : null; - const quoteState: 'none' | 'loading' | 'ready' | 'unavailable' = isPickup ? 'none' : checkout.isServiceQuoteUnavailable ? 'unavailable' : checkout.serviceQuote ? 'ready' : location ? 'loading' : 'none'; + const quoteState: 'none' | 'loading' | 'ready' | 'unavailable' = isPickup + ? 'none' + : checkout.isServiceQuoteUnavailable + ? 'unavailable' + : checkout.serviceQuote + ? 'ready' + : location + ? 'loading' + : 'none'; const discount = promo.promotions.discount; const total = Math.max(0, checkout.totalAmount - discount); @@ -108,7 +133,13 @@ export default function CheckoutLayout({ checkout, payment, extra, paymentReady, ? t('Checkout.preparing') : null; - const signIn = () => navigation.navigate(mode === 'network' ? 'NetworkProfileTab' : 'StoreProfileTab', { screen: 'Login' }); + // Sign in on the account tab, then come straight back to this checkout. + const signIn = () => { + const tabs = navigation.getParent()?.getState?.(); + const tab = tabs?.routes?.[tabs.index]?.name; + const redirect = tab ? { route: tab, params: { screen: route.name, params: route.params } } : null; + navigation.navigate(mode === 'network' ? 'NetworkProfileTab' : 'StoreProfileTab', { screen: 'Login', params: { redirectTo: 'checkout', redirect } }); + }; const addAddress = () => { setAddressSheet(false); navigation.navigate('LocationPicker', { redirectTo: 'Checkout' }); @@ -147,7 +178,9 @@ export default function CheckoutLayout({ checkout, payment, extra, paymentReady, - {location ? placeAttr('name') || placeAttr('street1') || t('Checkout.deliveryAddress') : t('Checkout.noAddress')} + + {location ? placeAttr('name') || placeAttr('street1') || t('Checkout.deliveryAddress') : t('Checkout.noAddress')} + {!!location && ( {[placeAttr('street1'), placeAttr('street2'), placeAttr('city'), placeAttr('postal_code')].filter(Boolean).join(', ')} @@ -259,8 +292,23 @@ export default function CheckoutLayout({ checkout, payment, extra, paymentReady, {(tipsEnabled || driverTipsEnabled) && ( - {driverTipsEnabled && checkout.setTipOptions({ leavingDeliveryTip: tip !== 0, deliveryTip: tip })} />} - {tipsEnabled && checkout.setTipOptions({ leavingTip: tip !== 0, tip })} />} + {driverTipsEnabled && ( + checkout.setTipOptions({ leavingDeliveryTip: tip !== 0, deliveryTip: tip })} + /> + )} + {tipsEnabled && ( + checkout.setTipOptions({ leavingTip: tip !== 0, tip })} + /> + )} )} @@ -291,11 +339,22 @@ export default function CheckoutLayout({ checkout, payment, extra, paymentReady, {summaryRows.map((row) => { const isFee = row.name === t('lineItems.serviceFee'); - const label = row.name === t('lineItems.cartSubtotal') ? t('Checkout.subtotalItems', { items: t('UI.itemsCount', { count: itemCount }) }) : isFee ? t('Checkout.deliveryFee') : row.name; + const label = + row.name === t('lineItems.cartSubtotal') + ? t('Checkout.subtotalItems', { items: t('UI.itemsCount', { count: itemCount }) }) + : isFee + ? t('Checkout.deliveryFee') + : row.name; return ( {label} - {row.loading ? : isFee && quoteState === 'unavailable' ? {t('Checkout.unavailable')} : {money(row.value)}} + {row.loading ? ( + + ) : isFee && quoteState === 'unavailable' ? ( + {t('Checkout.unavailable')} + ) : ( + {money(row.value)} + )} ); })} @@ -313,14 +372,34 @@ export default function CheckoutLayout({ checkout, payment, extra, paymentReady, {t('Checkout.notesLabel')} - + {children} - + {!isAuthenticated ? ( - - - - - - - - - - ); -}; - -export default AddPhoneScreen; diff --git a/src/screens/CreateAccountScreen.tsx b/src/screens/CreateAccountScreen.tsx deleted file mode 100644 index a374f7ca..00000000 --- a/src/screens/CreateAccountScreen.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView, Pressable, Keyboard, StyleSheet, Platform } from 'react-native'; -import { Spinner, XStack, Text, YStack, useTheme, Button } from 'tamagui'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faPaperPlane, faKey, faArrowRight } from '@fortawesome/free-solid-svg-icons'; -import { isValidPhoneNumber } from '../utils'; -import { toast } from '../utils/toast'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; -import PhoneInput from '../components/PhoneInput'; -import BackButton from '../components/BackButton'; -import Input from '../components/Input'; -import AbsoluteTabBarScreenWrapper from '../components/AbsoluteTabBarScreenWrapper'; - -const isAndroid = Platform.OS === 'android'; -const CreateAccountScreen = ({ route }) => { - const params = route.params || {}; - const navigation = useNavigation(); - const theme = useTheme(); - const { t } = useLanguage(); - const { requestCreationCode, isSendingCode, phone: phoneState } = useAuth(); - const [phone, setPhone] = useState(phoneState); - const [name, setName] = useState(params.name); - - const handleSendVerificationCode = async () => { - if (isSendingCode) { - return; - } - - if (!isValidPhoneNumber(phone)) { - return toast.error(t('CreateAccountScreen.invalidPhone')); - } - - try { - await requestCreationCode(phone); - navigation.navigate('CreateAccountVerify', { name, phone }); - } catch (error) { - toast.error(error.message); - } - }; - - const handleLogin = () => { - navigation.navigate('Login'); - }; - - return ( - - - - - - - - {t('CreateAccountScreen.createAccount')} - - - - setName(text)} - placeholder={t('CreateAccountScreen.enterYourName')} - color='$textPrimary' - placeholderTextColor='$textSecondary' - /> - setPhone(phoneNumber)} /> - - - - - - - - - - - - - ); -}; - -export default CreateAccountScreen; diff --git a/src/screens/CreateAccountVerifyScreen.tsx b/src/screens/CreateAccountVerifyScreen.tsx deleted file mode 100644 index d5c39a3c..00000000 --- a/src/screens/CreateAccountVerifyScreen.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView } from 'react-native'; -import { Spinner, Button, Input, Stack, Text, YStack, useTheme } from 'tamagui'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faCheck, faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'; -import { OtpInput } from 'react-native-otp-entry'; -import { toast } from '../utils/toast'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; - -const CreateAccountVerifyScreen = ({ route }) => { - const navigation = useNavigation(); - const theme = useTheme(); - const { phone, verifyAccountCreation, isVerifyingCode } = useAuth(); - const { t } = useLanguage(); - const [code, setCode] = useState(null); - const name = route.params.name; - - const handleVerifyCode = async (code) => { - if (isVerifyingCode) { - return; - } - - try { - await verifyAccountCreation(phone, code, { name, phone }); - } catch (error) { - toast.error(error?.message ?? 'Code verification failed.'); - } - }; - - const handleRetry = () => { - setCode(''); - navigation.navigate('CreateAccount', { phone, name }); - }; - - return ( - - - - - {t('CreateAccountVerifyScreen.codeSent', { phone })} - - - - - - - - ); -}; - -export default CreateAccountVerifyScreen; diff --git a/src/screens/DeleteAccountVerifyScreen.tsx b/src/screens/DeleteAccountVerifyScreen.tsx deleted file mode 100644 index 71134cae..00000000 --- a/src/screens/DeleteAccountVerifyScreen.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView, Pressable, Keyboard, StyleSheet } from 'react-native'; -import { Spinner, Button, Text, YStack, useTheme } from 'tamagui'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faCheck, faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'; -import { OtpInput } from 'react-native-otp-entry'; -import { toast } from '../utils/toast'; -import AbsoluteTabBarScreenWrapper from '../components/AbsoluteTabBarScreenWrapper'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; - -const DeleteAccountVerifyScreen = () => { - const navigation = useNavigation(); - const theme = useTheme(); - const { verifyAccountDeletion } = useAuth(); - const { t } = useLanguage(); - const [code, setCode] = useState(''); - const [isVerifying, setIsVerifying] = useState(false); - - const handleVerifyCode = async (enteredCode: string) => { - if (isVerifying) return; - setIsVerifying(true); - try { - await verifyAccountDeletion(enteredCode); - toast.success(t('DeleteAccountVerifyScreen.accountDeleted')); - } catch (error: any) { - toast.error(error.message); - } finally { - setIsVerifying(false); - } - }; - - const handleRetry = () => { - navigation.goBack(); - }; - - return ( - - - - - - {t('DeleteAccountVerifyScreen.enterDeletionCode')} - - - {t('DeleteAccountVerifyScreen.deletionCodePrompt')} - - - - - - - - - - - - ); -}; - -export default DeleteAccountVerifyScreen; diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx deleted file mode 100644 index 1873905c..00000000 --- a/src/screens/LoginScreen.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView, ImageBackground, StyleSheet } from 'react-native'; -import { Spinner, Stack, Text, YStack, useTheme, Button } from 'tamagui'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; -import { toast } from '../utils/toast'; -import { titleize } from '../utils/format'; -import { storefrontConfig } from '../utils'; -import { useLanguage } from '../contexts/LanguageContext'; -import { PhoneLoginButton, AppleLoginButton, FacebookLoginButton, GoogleLoginButton } from '../components/Buttons'; -import useOAuth from '../hooks/use-oauth'; -import LinearGradient from 'react-native-linear-gradient'; -import AbsoluteTabBarScreenWrapper from '../components/AbsoluteTabBarScreenWrapper'; -import storage from '../utils/storage'; - -const SHOW_CLEAR_CACHE = false; -const LoginScreen = () => { - const navigation = useNavigation(); - const theme = useTheme(); - const { login, loginSupported, loading } = useOAuth(); - const { t } = useLanguage(); - - const handleClearCache = () => { - storage.clearStore(); - toast.success(t('LoginScreen.cacheCleared')); - }; - - const handlePhoneLogin = () => { - navigation.navigate('PhoneLogin'); - }; - - const handleCreateAccount = () => { - navigation.navigate('CreateAccount'); - }; - - const handleOAuthLogin = async (provider) => { - try { - const response = await login(provider); - toast.success(t('LoginScreen.loggedInWithProvider', { provider: titleize(provider) })); - } catch (err) { - toast.warning(t('LoginScreen.loginAttemptFailed', { provider: titleize(provider) })); - console.error('Error attempting OAuth login:', err); - } - }; - - return ( - - - - - - - {loginSupported('apple') && handleOAuthLogin('apple')} />} - {loginSupported('facebook') && handleOAuthLogin('facebook')} />} - {loginSupported('google') && handleOAuthLogin('google')} />} - {SHOW_CLEAR_CACHE && ( - - )} - - - - {loading && ( - - - - )} - - - ); -}; - -const styles = StyleSheet.create({ - background: { - flex: 1, - width: '100%', - height: '100%', - }, -}); - -export default LoginScreen; diff --git a/src/screens/PhoneLoginScreen.tsx b/src/screens/PhoneLoginScreen.tsx deleted file mode 100644 index 7af719ad..00000000 --- a/src/screens/PhoneLoginScreen.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView, Pressable, Keyboard, StyleSheet, Platform } from 'react-native'; -import { Spinner, Input, Stack, Text, YStack, useTheme, Button } from 'tamagui'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faPaperPlane, faKey, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'; -import { isValidPhoneNumber } from '../utils'; -import { toast } from '../utils/toast'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; -import PhoneInput from '../components/PhoneInput'; -import AbsoluteTabBarScreenWrapper from '../components/AbsoluteTabBarScreenWrapper'; -import ScreenWrapper from '../components/ScreenWrapper'; - -const isAndroid = Platform.OS === 'android'; -const PhoneLoginScreen = () => { - const navigation = useNavigation(); - const theme = useTheme(); - const { login, isSendingCode, phone: phoneState } = useAuth(); - const { t } = useLanguage(); - const [phone, setPhone] = useState(phoneState); - - const handleSendVerificationCode = async () => { - if (isSendingCode) { - return; - } - - if (!isValidPhoneNumber(phone)) { - return toast.error(t('PhoneLoginScreen.invalidPhone')); - } - - try { - await login(phone); - navigation.navigate('PhoneLoginVerify'); - } catch (error) { - toast.error(error.message); - } - }; - - const handleUseAnotherMethod = () => { - navigation.goBack(); - }; - - const handleCreateAccount = () => { - navigation.navigate('CreateAccount'); - }; - - return ( - - - - - - {t('PhoneLoginScreen.loginViaSms')} - - setPhone(phoneNumber)} /> - - - - - - - - - - - - - ); -}; - -export default PhoneLoginScreen; diff --git a/src/screens/PhoneLoginVerifyScreen.tsx b/src/screens/PhoneLoginVerifyScreen.tsx deleted file mode 100644 index 64abb23c..00000000 --- a/src/screens/PhoneLoginVerifyScreen.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { useEffect, useState, useCallback } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView, Pressable, Keyboard, StyleSheet } from 'react-native'; -import { Spinner, Button, Input, Stack, Text, YStack, useTheme } from 'tamagui'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faCheck, faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'; -import { OtpInput } from 'react-native-otp-entry'; -import { toast } from '../utils/toast'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; -import AbsoluteTabBarScreenWrapper from '../components/AbsoluteTabBarScreenWrapper'; -import ScreenWrapper from '../components/ScreenWrapper'; - -const PhoneLoginVerifyScreen = () => { - const navigation = useNavigation(); - const theme = useTheme(); - const { phone, verifyCode, isVerifyingCode } = useAuth(); - const { t } = useLanguage(); - const [code, setCode] = useState(null); - - const handleVerifyCode = async (code) => { - if (isVerifyingCode) { - return; - } - - try { - await verifyCode(code); - } catch (error) { - toast.error(error?.message ?? 'Code verification failed.'); - } - }; - - const handleRetry = () => { - setCode(''); - navigation.navigate('PhoneLogin', { phone }); - }; - - return ( - - - - - - {t('PhoneLoginVerifyScreen.codeSentTo', { phone })} - - - - - - - - - - - - ); -}; - -export default PhoneLoginVerifyScreen; diff --git a/src/screens/ProfileScreen.tsx b/src/screens/ProfileScreen.tsx deleted file mode 100644 index 1cdfafbd..00000000 --- a/src/screens/ProfileScreen.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, { useState, useMemo, useCallback } from 'react'; -import { useNavigation, useFocusEffect } from '@react-navigation/native'; -import { SafeAreaView, FlatList, Pressable, Platform } from 'react-native'; -import { Avatar, Text, YStack, XStack, Separator, useTheme } from 'tamagui'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; -import { abbreviateName, storefrontConfig, showActionSheet } from '../utils'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; -import storage from '../utils/storage'; -import ScreenWrapper from '../components/ScreenWrapper'; - -const PAYMENT_GATEWAYS_NO_PAYMENT_METHODS = ['qpay']; -const ProfileScreen = () => { - const theme = useTheme(); - const navigation = useNavigation(); - const { customer, logout } = useAuth(); - const { t, locale } = useLanguage(); - - // Check for phone number and prompt user to add it - useFocusEffect( - useCallback(() => { - const checkPhoneNumber = () => { - if (customer && !customer.getAttribute('phone')) { - showActionSheet({ - title: t('ProfileScreen.addPhoneTitle'), - message: t('ProfileScreen.addPhoneMessage'), - options: [t('ProfileScreen.addPhoneButton'), t('ProfileScreen.addPhoneLater')], - cancelButtonIndex: 1, - onSelect: (buttonIndex) => { - if (buttonIndex === 0) { - navigation.navigate('AddPhone', { returnTo: 'Profile' }); - } - // buttonIndex 1 is "Later" - just dismiss - }, - }); - } - }; - - // Add a small delay to ensure the screen is fully rendered - const timer = setTimeout(checkPhoneNumber, 1000); - - return () => clearTimeout(timer); - }, [customer, navigation, t]) - ); - - const handleManagePaymentMethods = useCallback(() => { - if (storefrontConfig('paymentGateway') === 'stripe') { - return navigation.navigate('StripeCustomer'); - } - }, [navigation]); - - const handlePressMenuItem = useCallback( - (item) => { - if (item && typeof item.handler === 'function') { - return item.handler(item); - } - - return navigation.navigate(item.screen); - }, - [navigation] - ); - - const handleViewProfile = useCallback(() => { - navigation.navigate('Account'); - }, [navigation]); - - const menuItems = useMemo(() => { - const items = [ - { id: '1', title: t('ProfileScreen.orderHistory'), screen: 'OrderHistory' }, - { id: '2', title: t('ProfileScreen.account'), screen: 'Account' }, - { - id: '3', - title: t('ProfileScreen.paymentMethods'), - screen: 'PaymentMethods', - handler: handleManagePaymentMethods, - hidden: PAYMENT_GATEWAYS_NO_PAYMENT_METHODS.includes(storefrontConfig('paymentGateway')), - }, - { id: '4', title: t('ProfileScreen.addressBook'), screen: 'AddressBook' }, - ]; - - return items.filter((item) => !item.hidden); - }, [handleManagePaymentMethods, locale]); - - const renderMenuItem = ({ item }) => ( - handlePressMenuItem(item)} - style={({ pressed }) => ({ - backgroundColor: pressed ? theme.secondary.val : theme.background.val, - padding: 16, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - })} - > - - {item.title} - - - - ); - - return ( - - - - - - {t('ProfileScreen.greeting', { customerName: customer.getAttribute('name') })} - - - - - - - - - {abbreviateName(customer.getAttribute('name'))} - - - - - - - - item.id} - renderItem={renderMenuItem} - ItemSeparatorComponent={() => } - scrollEnabled={false} - /> - - - - ); -}; - -export default ProfileScreen; diff --git a/src/screens/VerifyPhoneScreen.tsx b/src/screens/VerifyPhoneScreen.tsx deleted file mode 100644 index 26ca0bf7..00000000 --- a/src/screens/VerifyPhoneScreen.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import { useNavigation } from '@react-navigation/native'; -import { SafeAreaView, Pressable, Keyboard, StyleSheet } from 'react-native'; -import { Spinner, Button, Text, YStack, useTheme } from 'tamagui'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faCheck, faArrowRotateRight } from '@fortawesome/free-solid-svg-icons'; -import { OtpInput } from 'react-native-otp-entry'; -import { toast } from '../utils/toast'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; -import { useSafeTabBarHeight } from '../hooks/use-safe-tab-bar-height'; - -const VerifyPhoneScreen = ({ route }) => { - const navigation = useNavigation(); - const returnTo = route?.params?.returnTo || 'Profile'; - const theme = useTheme(); - const { phone, verifyPhoneNumber, isVerifyingCode } = useAuth(); - const { t } = useLanguage(); - const tabBarHeight = useSafeTabBarHeight(); - const [code, setCode] = useState(null); - - const handleVerifyCode = async (code) => { - if (isVerifyingCode) { - return; - } - - try { - await verifyPhoneNumber(code); - toast.success(t('VerifyPhoneScreen.success')); - // Navigate to the screen specified by returnTo parameter - navigation.navigate(returnTo); - } catch (error) { - toast.error(error?.message ?? t('VerifyPhoneScreen.error')); - } - }; - - const handleRetry = () => { - setCode(''); - navigation.goBack(); - }; - - return ( - - - - - {t('VerifyPhoneScreen.title', { phone })} - - - {t('VerifyPhoneScreen.description')} - - - - - - - - - - - ); -}; - -export default VerifyPhoneScreen; diff --git a/src/screens/account/AccountHomeScreen.tsx b/src/screens/account/AccountHomeScreen.tsx new file mode 100644 index 00000000..eb934214 --- /dev/null +++ b/src/screens/account/AccountHomeScreen.tsx @@ -0,0 +1,343 @@ +import React, { useMemo, useState } from 'react'; +import { Linking, Pressable, ScrollView } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { + faBell, + faChevronRight, + faCircleHalfStroke, + faCreditCard, + faFileLines, + faGlobe, + faLocationDot, + faMobileScreen, + faReceipt, + faShieldHalved, + faTag, + faTruckFast, +} from '@fortawesome/free-solid-svg-icons'; +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { XStack, YStack, useTheme } from 'tamagui'; +import { useAuth } from '../../contexts/AuthContext'; +import { useLanguage } from '../../contexts/LanguageContext'; +import useAppTheme from '../../hooks/use-app-theme'; +import useStorage from '../../hooks/use-storage'; +import useUnreadNotifications from '../../hooks/use-unread-notifications'; +import useFooterOffset from '../../hooks/use-footer-offset'; +import { orderProgress } from '../../commerce/order-progress'; +import { storefrontConfig } from '../../utils'; +import { toast } from '../../utils/toast'; +import { Badge, Button, Card, MediaImage, Sheet, UIText, initials, radius, space, tintFor } from '../../ui'; + +type Row = { key: string; icon: IconDefinition; label: string; value?: string | null; badge?: number; onPress: () => void; hidden?: boolean }; + +const GATEWAYS_WITHOUT_SAVED_METHODS = ['qpay']; + +function MenuGroup({ title, rows }: { title?: string; rows: Row[] }) { + const theme = useTheme(); + const visible = rows.filter((row) => !row.hidden); + if (!visible.length) return null; + return ( + + {!!title && ( + + {title} + + )} + + {visible.map((row, index) => ( + ({ + minHeight: 54, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 14, + backgroundColor: pressed ? (theme.backgroundPress?.val ?? theme.surface.val) : 'transparent', + borderTopWidth: index ? 1 : 0, + borderColor: theme.borderColor.val, + })} + > + + + {row.label} + + {!!row.badge && } + {!!row.value && ( + + {row.value} + + )} + + + ))} + + + ); +} + +/** Choose one option from a short list, e.g. the language or appearance. */ +function ChoiceSheet({ + open, + title, + options, + selected, + onSelect, + onClose, +}: { + open: boolean; + title: string; + options: { key: string; label: string }[]; + selected: string; + onSelect: (key: string) => void; + onClose: () => void; +}) { + const theme = useTheme(); + return ( + + + {options.map((option) => ( + { + onSelect(option.key); + onClose(); + }} + accessibilityRole='radio' + accessibilityState={{ checked: option.key === selected }} + style={{ minHeight: 52, flexDirection: 'row', alignItems: 'center', borderBottomWidth: 1, borderColor: theme.borderColor.val }} + > + + {option.label} + + + + ))} + + + ); +} + +/** + * The signed-in customer's account: who they are, an active order to jump back to, + * shortcuts to orders, offers, places, payment and notifications, preferences, and + * signing out or deleting the account. + */ +const AccountHomeScreen = () => { + const navigation = useNavigation(); + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const footer = useFooterOffset(false); + const { t, locale, setLocale, languages = [] } = useLanguage(); + const { userColorScheme, changeScheme, schemes } = useAppTheme(); + const { customer, logout, deleteAccount } = useAuth() as any; + const { count: unread } = useUnreadNotifications(); + const [storedOrders] = useStorage(`${customer?.id}_orders`, []); + const [sheet, setSheet] = useState<'language' | 'appearance' | 'delete' | null>(null); + const [deleting, setDeleting] = useState(false); + + const name: string = customer?.getAttribute?.('name') ?? ''; + const email: string | null = customer?.getAttribute?.('email') ?? null; + const phone: string | null = customer?.getAttribute?.('phone') ?? null; + const photo: string | null = customer?.getAttribute?.('photo_url') ?? null; + const gateway = storefrontConfig('paymentGateway'); + const terms = storefrontConfig('termsUrl'); + const privacy = storefrontConfig('privacyUrl'); + + // The most recent order still in progress, from the order history cache. + const activeOrder = useMemo(() => { + const orders = Array.isArray(storedOrders) ? storedOrders : []; + return orders.find((order: any) => order?.id && !orderProgress({ status: order.status, isPickup: order.meta?.is_pickup === true }).finished) ?? null; + }, [storedOrders]); + + const languageName = languages.find((language: any) => language.code === locale)?.native ?? locale.toUpperCase(); + const schemeLabel = (scheme: string) => t(`Account.scheme.${scheme}`); + + const requestDeletion = async () => { + setDeleting(true); + try { + await deleteAccount(); + setSheet(null); + navigation.navigate('DeleteAccountVerify', { phone }); + } catch (failure: any) { + toast.error(failure?.message || t('Auth.sendFailed')); + } finally { + setDeleting(false); + } + }; + + return ( + + + + {t('Account.title')} + + + + {photo ? ( + + ) : ( + + + {initials(name)} + + + )} + + + {name} + + {!!(email || phone) && ( + + {email || phone} + + )} + + + + + {!phone && ( + + + + + {t('Account.addPhoneTitle')} + + {t('Account.addPhoneBody')} + + + + + + )} + + {!!activeOrder && ( + navigation.navigate('Order', { orderId: activeOrder.id })} + accessibilityRole='button' + accessibilityLabel={t('Account.activeOrderLabel', { id: activeOrder.public_id ?? activeOrder.id })} + > + + + + + + + + {t('Account.activeOrder')} + + + {t(`Tracking.phase.${orderProgress({ status: activeOrder.status, isPickup: activeOrder.meta?.is_pickup === true }).phase}.title`, { + store: '', + driver: t('Tracking.yourDriver'), + })} + + + + {t('Account.track')} + + + + + )} + + navigation.navigate('OrderHistory') }, + { key: 'offers', icon: faTag, label: t('Account.offers'), onPress: () => navigation.navigate('Offers') }, + { key: 'places', icon: faLocationDot, label: t('Account.savedPlaces'), onPress: () => navigation.navigate('AddressBook') }, + { + key: 'payment', + icon: faCreditCard, + label: t('Account.paymentMethods'), + hidden: gateway !== 'stripe' || GATEWAYS_WITHOUT_SAVED_METHODS.includes(gateway), + onPress: () => navigation.navigate('StripeCustomer'), + }, + { key: 'notifications', icon: faBell, label: t('Account.notifications'), badge: unread, onPress: () => navigation.navigate('Notifications') }, + ]} + /> + + setSheet('language') }, + { key: 'appearance', icon: faCircleHalfStroke, label: t('Account.appearance'), value: schemeLabel(userColorScheme), onPress: () => setSheet('appearance') }, + ]} + /> + + Linking.openURL(terms) }, + { key: 'privacy', icon: faShieldHalved, label: t('Auth.privacy'), hidden: !privacy, onPress: () => Linking.openURL(privacy) }, + ]} + /> + + + + + + + + ({ key: language.code, label: [language.emoji, language.native ?? language.name ?? language.code].filter(Boolean).join(' ') }))} + selected={locale} + onSelect={setLocale} + onClose={() => setSheet(null)} + /> + ({ key: scheme, label: schemeLabel(scheme) }))} + selected={userColorScheme} + onSelect={changeScheme} + onClose={() => setSheet(null)} + /> + setSheet(null)} + title={t('Account.deleteTitle')} + footer={ + + + + + } + > + {t('Account.deleteBody')} + + + ); +}; + +export default AccountHomeScreen; diff --git a/src/screens/auth/AddPhoneScreen.tsx b/src/screens/auth/AddPhoneScreen.tsx new file mode 100644 index 00000000..63b1d39f --- /dev/null +++ b/src/screens/auth/AddPhoneScreen.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import SignInScreen from './SignInScreen'; + +/** Add a phone number to the signed-in account: enter it, then verify the SMS code. */ +const AddPhoneScreen = (props: any) => ; + +export default AddPhoneScreen; diff --git a/src/screens/auth/CreateAccountScreen.tsx b/src/screens/auth/CreateAccountScreen.tsx new file mode 100644 index 00000000..e9824fb8 --- /dev/null +++ b/src/screens/auth/CreateAccountScreen.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import SignInScreen from './SignInScreen'; + +/** Sign up: the sign-in screen with a name field, sending an account creation code. */ +const CreateAccountScreen = (props: any) => ; + +export default CreateAccountScreen; diff --git a/src/screens/auth/SignInScreen.tsx b/src/screens/auth/SignInScreen.tsx new file mode 100644 index 00000000..eaa9a957 --- /dev/null +++ b/src/screens/auth/SignInScreen.tsx @@ -0,0 +1,199 @@ +import React, { useEffect, useState } from 'react'; +import { KeyboardAvoidingView, Linking, Platform, ScrollView } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { faApple, faFacebook, faGoogle } from '@fortawesome/free-brands-svg-icons'; +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { XStack, YStack } from 'tamagui'; +import { useAuth } from '../../contexts/AuthContext'; +import { useLanguage } from '../../contexts/LanguageContext'; +import { useStorefrontRuntime } from '../../contexts/StorefrontRuntimeContext'; +import useOAuth from '../../hooks/use-oauth'; +import { isValidPhoneNumber, storefrontConfig } from '../../utils'; +import { setAuthRedirect } from '../../navigation/auth-redirect'; +import PhoneField from '../../components/auth/PhoneField'; +import { Button, IconButton, TextField, UIText, space } from '../../ui'; + +const PROVIDERS = [ + { key: 'apple', icon: faApple, variant: 'inverse' }, + { key: 'google', icon: faGoogle, variant: 'outline' }, + { key: 'facebook', icon: faFacebook, variant: 'outline' }, +] as const; + +/** + * Phone-first sign in (and, with `mode: 'create'`, sign up with a name), plus the social + * sign-ins the build enables. A `redirectTo` param (for example the checkout) is where the + * customer lands once signed in. + */ +const SignInScreen = ({ route, mode: forcedMode }: any) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { t } = useLanguage(); + const { ownerInfo } = useStorefrontRuntime(); + const { login, requestCreationCode, requestPhoneVerification, phone: lastPhone } = useAuth() as any; + const oauth = useOAuth(); + const params = route?.params ?? {}; + const mode: 'signIn' | 'create' | 'phone' = forcedMode ?? params.mode ?? 'signIn'; + const [phone, setPhone] = useState(params.phone ?? lastPhone ?? ''); + const [name, setName] = useState(params.name ?? ''); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const fromCheckout = params.redirectTo === 'checkout'; + + // Remember where to go once signed in (e.g. back to checkout). + useEffect(() => { + if (params.redirect) setAuthRedirect(params.redirect); + }, [params.redirect]); + + const sendCode = async () => { + if (sending) return; + if (mode === 'create' && !name.trim()) return setError(t('Auth.nameRequired')); + if (!isValidPhoneNumber(phone)) return setError(t('Auth.invalidPhone')); + setError(null); + setSending(true); + try { + if (mode === 'phone') { + await requestPhoneVerification(phone); + navigation.navigate('VerifyPhone', { phone, returnTo: params.returnTo }); + } else if (mode === 'create') { + await requestCreationCode(phone); + navigation.navigate('CreateAccountVerify', { phone, name: name.trim() }); + } else { + await login(phone); + navigation.navigate('PhoneLoginVerify', { phone }); + } + } catch (failure: any) { + setError(failure?.message || t('Auth.sendFailed')); + } finally { + setSending(false); + } + }; + + const social = async (provider: string) => { + try { + await oauth.login(provider); + } catch { + setError(t('Auth.socialFailed', { provider: provider[0].toUpperCase() + provider.slice(1) })); + } + }; + + const providers = PROVIDERS.filter((provider) => oauth.loginSupported?.(provider.key)); + const terms = storefrontConfig('termsUrl'); + const privacy = storefrontConfig('privacyUrl'); + const appName = ownerInfo?.name ?? ''; + const title = mode === 'phone' ? t('Auth.addPhoneTitle') : mode === 'create' ? t('Auth.createTitle') : fromCheckout ? t('Auth.signInToOrder') : t('Auth.signInTitle'); + const subtitle = mode === 'phone' ? t('Auth.addPhoneBody') : fromCheckout ? t('Auth.cartSaved') : mode === 'create' ? t('Auth.createBody', { name: appName }) : t('Auth.signInBody'); + + return ( + + + + {navigation.canGoBack() && ( + + navigation.goBack()} /> + + )} + + + + {title} + + {subtitle} + + + {mode === 'create' && ( + + + {t('Auth.yourName')} + + + + )} + + + + {t('Auth.mobileNumber')} + + + {error ? ( + + {error} + + ) : ( + + {t('Auth.smsNote')} + + )} + + + + + {mode === 'signIn' && providers.length > 0 && ( + <> + + + + {t('Auth.or')} + + + + + {providers.map((provider) => ( + + ))} + + + )} + + {mode !== 'phone' && ( + + )} + + {mode !== 'phone' && (!!terms || !!privacy) && ( + + {t('Auth.agree', { name: appName })}{' '} + {!!terms && ( + Linking.openURL(terms)} style={{ textDecorationLine: 'underline' }}> + {t('Auth.terms')} + + )} + {!!terms && !!privacy && ` ${t('Auth.and')} `} + {!!privacy && ( + Linking.openURL(privacy)} style={{ textDecorationLine: 'underline' }}> + {t('Auth.privacy')} + + )} + + )} + + + + ); +}; + +export default SignInScreen; diff --git a/src/screens/auth/VerifyCodeScreen.tsx b/src/screens/auth/VerifyCodeScreen.tsx new file mode 100644 index 00000000..26a7c7f5 --- /dev/null +++ b/src/screens/auth/VerifyCodeScreen.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { faChevronLeft } from '@fortawesome/free-solid-svg-icons'; +import { XStack, YStack } from 'tamagui'; +import { useAuth } from '../../contexts/AuthContext'; +import { useLanguage } from '../../contexts/LanguageContext'; +import { takeAuthRedirect } from '../../navigation/auth-redirect'; +import { toast } from '../../utils/toast'; +import { Button, CodeInput, IconButton, UIText, space } from '../../ui'; + +export type VerifyPurpose = 'signIn' | 'create' | 'phone' | 'delete'; + +const RESEND_SECONDS = 30; + +/** "+65 9123 4567" style grouping for showing where the code went. */ +function display(phone: string | null | undefined): string { + if (!phone) return ''; + const digits = phone.replace(/[^\d]/g, ''); + return `+${digits.slice(0, digits.length - 8)} ${digits.slice(-8, -4)} ${digits.slice(-4)}`.trim(); +} + +/** + * Enter the 6-digit code sent by SMS, for signing in, creating an account, adding a phone + * number or confirming account deletion. Submits when the last digit is entered, shows the + * error inline, and offers a resend after 30 seconds. + */ +const VerifyCodeScreen = ({ route, purpose }: { route: any; purpose: VerifyPurpose }) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { t } = useLanguage(); + const auth: any = useAuth(); + const params = route?.params ?? {}; + const phone: string | null = params.phone ?? auth.phone ?? auth.customer?.getAttribute?.('phone') ?? null; + const [code, setCode] = useState(''); + const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(null); + const [wait, setWait] = useState(RESEND_SECONDS); + const timer = useRef | null>(null); + + const startTimer = () => { + setWait(RESEND_SECONDS); + if (timer.current) clearInterval(timer.current); + timer.current = setInterval(() => setWait((value) => (value <= 1 ? (clearInterval(timer.current!), 0) : value - 1)), 1000); + }; + + useEffect(() => { + startTimer(); + return () => { + if (timer.current) clearInterval(timer.current); + }; + }, []); + + const verify = async (value = code) => { + if (verifying || value.length < 6) return; + setVerifying(true); + setError(null); + try { + if (purpose === 'signIn') await auth.verifyCode(value); + else if (purpose === 'create') await auth.verifyAccountCreation(phone, value, { name: params.name }); + else if (purpose === 'phone') await auth.verifyPhoneNumber(value); + else await auth.verifyAccountDeletion(value); + finish(); + } catch { + setError(t('Auth.codeMismatch')); + setCode(''); + } finally { + setVerifying(false); + } + }; + + const finish = () => { + if (purpose === 'phone') { + toast.success(t('Auth.phoneSaved')); + navigation.navigate(params.returnTo ?? 'Profile'); + return; + } + if (purpose === 'delete') { + toast.success(t('Auth.accountDeleted')); + return; + } + // Signed in: the signed-out screens unmount; continue where the customer was going. + const redirect = takeAuthRedirect(); + if (redirect) navigation.navigate(redirect.route, redirect.params); + }; + + const resend = async () => { + if (wait > 0) return; + try { + if (purpose === 'signIn') await auth.login(phone); + else if (purpose === 'create') await auth.requestCreationCode(phone); + else if (purpose === 'phone') await auth.requestPhoneVerification(phone); + else await auth.deleteAccount(); + startTimer(); + toast.success(t('Auth.codeResent')); + } catch (failure: any) { + toast.error(failure?.message || t('Auth.sendFailed')); + } + }; + + const title = purpose === 'phone' ? t('Auth.verifyPhoneTitle') : purpose === 'delete' ? t('Auth.verifyDeleteTitle') : t('Auth.checkMessages'); + const cta = purpose === 'phone' ? t('Auth.verifyAndSave') : purpose === 'delete' ? t('Auth.deleteForGood') : t('Auth.verifyAndContinue'); + + return ( + + + + + navigation.goBack()} /> + {(purpose === 'signIn' || purpose === 'create') && ( + + {t('Auth.stepTwo')} + + )} + + + + + {title} + + + {t('Auth.codeSentTo')} {display(phone)} + {purpose !== 'delete' && ( + <> + {'. '} + navigation.goBack()}> + {t('Auth.changeNumber')} + + + )} + + {purpose === 'delete' && ( + + {t('Auth.deleteWarning')} + + )} + + + + { + setCode(value); + if (error) setError(null); + }} + onComplete={verify} + invalid={!!error} + label={t('Auth.codeLabel')} + /> + {!!error && ( + + {error} + + )} + + + + + + {wait > 0 ? ( + + {t('Auth.didntGetIt')} {t('Auth.resendIn', { seconds: `0:${String(wait).padStart(2, '0')}` })} + + ) : ( + + )} + + + + + ); +}; + +export default VerifyCodeScreen; + +export const SignInVerifyScreen = (props: any) => ; +export const CreateAccountVerifyScreen = (props: any) => ; +export const PhoneVerifyScreen = (props: any) => ; +export const DeleteAccountVerifyScreen = (props: any) => ; diff --git a/src/ui/CodeInput.tsx b/src/ui/CodeInput.tsx new file mode 100644 index 00000000..1e61e362 --- /dev/null +++ b/src/ui/CodeInput.tsx @@ -0,0 +1,61 @@ +import React, { useRef, useState } from 'react'; +import { Pressable, TextInput } from 'react-native'; +import { XStack, YStack, useTheme } from 'tamagui'; +import { UIText } from './Text'; +import { radius } from './tokens'; + +/** + * A one-time code entered into boxes. One hidden text field takes the input so paste and + * SMS autofill work; `onComplete` fires when every box is filled. + */ +export function CodeInput({ value, onChange, onComplete, length = 6, invalid = false, label, autoFocus = true }: { value: string; onChange: (code: string) => void; onComplete?: (code: string) => void; length?: number; invalid?: boolean; label: string; autoFocus?: boolean }) { + const theme = useTheme(); + const input = useRef(null); + const [focused, setFocused] = useState(false); + + const change = (text: string) => { + const code = text.replace(/\D/g, '').slice(0, length); + onChange(code); + if (code.length === length) onComplete?.(code); + }; + + return ( + input.current?.focus()} accessibilityRole='none'> + + {Array.from({ length }).map((_, index) => { + const active = focused && (index === value.length || (index === length - 1 && value.length === length)); + return ( + + {value[index] ?? ''} + + ); + })} + + setFocused(true)} + onBlur={() => setFocused(false)} + accessibilityLabel={label} + caretHidden + style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0.01, color: theme.background.val }} + /> + + ); +} diff --git a/src/ui/index.ts b/src/ui/index.ts index a47c57d5..57640d74 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -19,6 +19,7 @@ export { CartPill } from './CartPill'; export { Stepper, SegmentedControl } from './Controls'; export { Sheet } from './Sheet'; export { TextField } from './TextField'; +export { CodeInput } from './CodeInput'; export type { TextFieldProps } from './TextField'; export { LocationSheet } from './LocationSheet'; export { categoryIcon } from './category-icons'; diff --git a/translations/en.json b/translations/en.json index d25dcf6e..00044f1c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1040,5 +1040,88 @@ "closedBody": "Need help with the order? Contact {{store}} from the order page.", "closedNote": "Chat with your driver closed when the order was finished.", "viewMessages": "View messages" + }, + "Auth": { + "signInTitle": "Sign in", + "signInBody": "Enter your mobile number and we'll text you a code.", + "signInToOrder": "Sign in to place your order", + "cartSaved": "Your cart is saved. We'll bring you straight back to checkout.", + "createTitle": "Create your account", + "createBody": "Order from {{name}} in a few taps.", + "addPhoneTitle": "Add your phone", + "addPhoneBody": "Drivers and stores use it to reach you about your orders.", + "yourName": "Your name", + "namePlaceholder": "First and last name", + "nameRequired": "Enter your name.", + "mobileNumber": "Mobile number", + "phonePlaceholder": "Phone number", + "invalidPhone": "That number doesn't look right. Check it and try again.", + "smsNote": "We'll send a 6-digit code by SMS. Message rates may apply.", + "sendCode": "Send code", + "sendFailed": "We couldn't send a code. Try again.", + "or": "or", + "continueWith": { + "apple": "Continue with Apple", + "google": "Continue with Google", + "facebook": "Continue with Facebook" + }, + "socialFailed": "Signing in with {{provider}} didn't work. Try again or use your phone.", + "newHere": "New here? Create an account", + "haveAccount": "Already have an account? Sign in", + "agree": "By continuing you agree to {{name}}'s", + "terms": "Terms of service", + "and": "and", + "privacy": "Privacy policy", + "countryCode": "Country code, {{country}} +{{code}}", + "chooseCountry": "Choose a country", + "searchCountry": "Search countries", + "stepTwo": "Step 2 of 2", + "checkMessages": "Check your messages", + "verifyPhoneTitle": "Verify your phone", + "verifyDeleteTitle": "Confirm account deletion", + "codeSentTo": "We sent a 6-digit code to", + "changeNumber": "Change number", + "codeLabel": "6-digit code", + "codeMismatch": "That code didn't match. Check it and try again.", + "verifyAndContinue": "Verify and continue", + "verifyAndSave": "Verify and save", + "deleteForGood": "Delete my account", + "deleteWarning": "This permanently removes your account, order history and saved places.", + "didntGetIt": "Didn't get it?", + "resendIn": "Resend in {{seconds}}", + "resend": "Resend code", + "codeResent": "We sent a new code.", + "phoneSaved": "Phone number saved", + "accountDeleted": "Your account has been deleted" + }, + "Account": { + "title": "Account", + "edit": "Edit", + "addPhoneTitle": "Add your phone number", + "addPhoneBody": "So your driver and the store can reach you about an order.", + "addPhone": "Add phone", + "activeOrder": "Active order", + "activeOrderLabel": "Active order {{id}}, track it", + "track": "Track", + "orders": "Orders", + "offers": "Offers", + "savedPlaces": "Saved places", + "paymentMethods": "Payment methods", + "notifications": "Notifications", + "preferences": "Preferences", + "language": "Language", + "appearance": "Appearance", + "scheme": { + "system": "System", + "light": "Light", + "dark": "Dark" + }, + "about": "About", + "signOut": "Sign out", + "deleteAccount": "Delete account", + "deleteTitle": "Delete your account?", + "deleteBody": "We'll text you a code to confirm. Deleting removes your account, order history and saved places, and can't be undone.", + "sendDeleteCode": "Send confirmation code", + "keepAccount": "Keep my account" } } diff --git a/translations/mn.json b/translations/mn.json index bca4a428..89c6382b 100644 --- a/translations/mn.json +++ b/translations/mn.json @@ -1040,5 +1040,88 @@ "closedBody": "Захиалгын талаар тусламж хэрэгтэй юу? Захиалгын хуудаснаас {{store}}-тэй холбогдоно уу.", "closedNote": "Захиалга дууссан тул жолоочтой хийсэн чат хаагдсан.", "viewMessages": "Мессежүүдийг харах" + }, + "Auth": { + "signInTitle": "Нэвтрэх", + "signInBody": "Утасны дугаараа оруулбал бид танд код илгээнэ.", + "signInToOrder": "Захиалга өгөхийн тулд нэвтэрнэ үү", + "cartSaved": "Таны сагс хадгалагдсан. Нэвтэрсний дараа төлбөр рүү шууд буцаана.", + "createTitle": "Бүртгэл үүсгэх", + "createBody": "{{name}}-с хэдхэн товшилтоор захиалаарай.", + "addPhoneTitle": "Утасны дугаар нэмэх", + "addPhoneBody": "Жолооч болон дэлгүүр таны захиалгын талаар холбогдоход ашиглана.", + "yourName": "Таны нэр", + "namePlaceholder": "Нэр, овог", + "nameRequired": "Нэрээ оруулна уу.", + "mobileNumber": "Утасны дугаар", + "phonePlaceholder": "Утасны дугаар", + "invalidPhone": "Дугаар буруу байна. Шалгаад дахин оролдоно уу.", + "smsNote": "Бид 6 оронтой кодыг SMS-ээр илгээнэ.", + "sendCode": "Код илгээх", + "sendFailed": "Код илгээж чадсангүй. Дахин оролдоно уу.", + "or": "эсвэл", + "continueWith": { + "apple": "Apple-р үргэлжлүүлэх", + "google": "Google-р үргэлжлүүлэх", + "facebook": "Facebook-р үргэлжлүүлэх" + }, + "socialFailed": "{{provider}}-р нэвтэрч чадсангүй. Дахин оролдох эсвэл утсаараа нэвтэрнэ үү.", + "newHere": "Шинэ хэрэглэгч үү? Бүртгэл үүсгэх", + "haveAccount": "Бүртгэлтэй юу? Нэвтрэх", + "agree": "Үргэлжлүүлснээр та {{name}}-н", + "terms": "Үйлчилгээний нөхцөл", + "and": "болон", + "privacy": "Нууцлалын бодлого", + "countryCode": "Улсын код, {{country}} +{{code}}", + "chooseCountry": "Улс сонгох", + "searchCountry": "Улс хайх", + "stepTwo": "2-р алхам", + "checkMessages": "Мессежээ шалгана уу", + "verifyPhoneTitle": "Утсаа баталгаажуулах", + "verifyDeleteTitle": "Бүртгэл устгахыг баталгаажуулах", + "codeSentTo": "Бид 6 оронтой код илгээлээ:", + "changeNumber": "Дугаар солих", + "codeLabel": "6 оронтой код", + "codeMismatch": "Код таарсангүй. Шалгаад дахин оролдоно уу.", + "verifyAndContinue": "Баталгаажуулж үргэлжлүүлэх", + "verifyAndSave": "Баталгаажуулж хадгалах", + "deleteForGood": "Бүртгэлээ устгах", + "deleteWarning": "Таны бүртгэл, захиалгын түүх, хадгалсан хаягууд бүрмөсөн устна.", + "didntGetIt": "Код ирээгүй юу?", + "resendIn": "{{seconds}}-н дараа дахин илгээх", + "resend": "Код дахин илгээх", + "codeResent": "Шинэ код илгээлээ.", + "phoneSaved": "Утасны дугаар хадгалагдлаа", + "accountDeleted": "Таны бүртгэл устгагдлаа" + }, + "Account": { + "title": "Бүртгэл", + "edit": "Засах", + "addPhoneTitle": "Утасны дугаараа нэмнэ үү", + "addPhoneBody": "Жолооч болон дэлгүүр захиалгын талаар тантай холбогдох боломжтой болно.", + "addPhone": "Утас нэмэх", + "activeOrder": "Идэвхтэй захиалга", + "activeOrderLabel": "Идэвхтэй захиалга {{id}}, хянах", + "track": "Хянах", + "orders": "Захиалгууд", + "offers": "Урамшуулал", + "savedPlaces": "Хадгалсан хаягууд", + "paymentMethods": "Төлбөрийн хэрэгсэл", + "notifications": "Мэдэгдэл", + "preferences": "Тохиргоо", + "language": "Хэл", + "appearance": "Харагдах байдал", + "scheme": { + "system": "Системийн", + "light": "Цайвар", + "dark": "Бараан" + }, + "about": "Тухай", + "signOut": "Гарах", + "deleteAccount": "Бүртгэл устгах", + "deleteTitle": "Бүртгэлээ устгах уу?", + "deleteBody": "Баталгаажуулах кодыг SMS-ээр илгээнэ. Устгавал таны бүртгэл, захиалгын түүх, хадгалсан хаягууд бүрмөсөн устах бөгөөд сэргээх боломжгүй.", + "sendDeleteCode": "Баталгаажуулах код илгээх", + "keepAccount": "Бүртгэлээ үлдээх" } } diff --git a/translations/uk.json b/translations/uk.json index ee5971da..ba22891e 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -1054,5 +1054,88 @@ "closedBody": "Потрібна допомога із замовленням? Зв’яжіться з {{store}} на сторінці замовлення.", "closedNote": "Чат із водієм закрито після завершення замовлення.", "viewMessages": "Переглянути повідомлення" + }, + "Auth": { + "signInTitle": "Увійти", + "signInBody": "Введіть номер телефону, і ми надішлемо вам код.", + "signInToOrder": "Увійдіть, щоб оформити замовлення", + "cartSaved": "Ваш кошик збережено. Після входу ми повернемо вас до оформлення.", + "createTitle": "Створіть обліковий запис", + "createBody": "Замовляйте в {{name}} за кілька дотиків.", + "addPhoneTitle": "Додайте телефон", + "addPhoneBody": "Водії та магазини використовують його, щоб зв'язатися з вами щодо замовлень.", + "yourName": "Ваше ім'я", + "namePlaceholder": "Ім'я та прізвище", + "nameRequired": "Введіть ім'я.", + "mobileNumber": "Номер мобільного", + "phonePlaceholder": "Номер телефону", + "invalidPhone": "Номер виглядає неправильним. Перевірте та спробуйте ще раз.", + "smsNote": "Ми надішлемо 6-значний код у SMS. Може стягуватися плата.", + "sendCode": "Надіслати код", + "sendFailed": "Не вдалося надіслати код. Спробуйте ще раз.", + "or": "або", + "continueWith": { + "apple": "Продовжити з Apple", + "google": "Продовжити з Google", + "facebook": "Продовжити з Facebook" + }, + "socialFailed": "Не вдалося увійти через {{provider}}. Спробуйте ще раз або використайте телефон.", + "newHere": "Вперше тут? Створіть обліковий запис", + "haveAccount": "Вже маєте обліковий запис? Увійти", + "agree": "Продовжуючи, ви погоджуєтеся з умовами {{name}}:", + "terms": "Умови використання", + "and": "та", + "privacy": "Політика конфіденційності", + "countryCode": "Код країни, {{country}} +{{code}}", + "chooseCountry": "Оберіть країну", + "searchCountry": "Пошук країн", + "stepTwo": "Крок 2 з 2", + "checkMessages": "Перевірте повідомлення", + "verifyPhoneTitle": "Підтвердьте телефон", + "verifyDeleteTitle": "Підтвердьте видалення облікового запису", + "codeSentTo": "Ми надіслали 6-значний код на", + "changeNumber": "Змінити номер", + "codeLabel": "6-значний код", + "codeMismatch": "Код не збігається. Перевірте та спробуйте ще раз.", + "verifyAndContinue": "Підтвердити та продовжити", + "verifyAndSave": "Підтвердити та зберегти", + "deleteForGood": "Видалити мій обліковий запис", + "deleteWarning": "Це назавжди видалить ваш обліковий запис, історію замовлень і збережені адреси.", + "didntGetIt": "Не отримали?", + "resendIn": "Повторно через {{seconds}}", + "resend": "Надіслати код ще раз", + "codeResent": "Ми надіслали новий код.", + "phoneSaved": "Номер телефону збережено", + "accountDeleted": "Ваш обліковий запис видалено" + }, + "Account": { + "title": "Обліковий запис", + "edit": "Редагувати", + "addPhoneTitle": "Додайте номер телефону", + "addPhoneBody": "Щоб водій і магазин могли зв'язатися з вами щодо замовлення.", + "addPhone": "Додати телефон", + "activeOrder": "Активне замовлення", + "activeOrderLabel": "Активне замовлення {{id}}, відстежити", + "track": "Відстежити", + "orders": "Замовлення", + "offers": "Пропозиції", + "savedPlaces": "Збережені адреси", + "paymentMethods": "Способи оплати", + "notifications": "Сповіщення", + "preferences": "Налаштування", + "language": "Мова", + "appearance": "Вигляд", + "scheme": { + "system": "Системний", + "light": "Світлий", + "dark": "Темний" + }, + "about": "Про застосунок", + "signOut": "Вийти", + "deleteAccount": "Видалити обліковий запис", + "deleteTitle": "Видалити обліковий запис?", + "deleteBody": "Ми надішлемо код у SMS для підтвердження. Видалення прибере ваш обліковий запис, історію замовлень і збережені адреси, і його не можна скасувати.", + "sendDeleteCode": "Надіслати код підтвердження", + "keepAccount": "Залишити обліковий запис" } } diff --git a/web/react-native-google-signin.web.js b/web/react-native-google-signin.web.js new file mode 100644 index 00000000..d3680caa --- /dev/null +++ b/web/react-native-google-signin.web.js @@ -0,0 +1,18 @@ +// Native Google Sign-In has no web implementation, and its package fails to load in the +// web bundle. The web build hides the Google button; this keeps imports working. +const unsupported = () => Promise.reject(new Error('Google Sign-In is not available on the web.')); + +export const GoogleSignin = { + configure() {}, + hasPlayServices: () => Promise.resolve(false), + signIn: unsupported, + signInSilently: unsupported, + signOut: () => Promise.resolve(null), + revokeAccess: () => Promise.resolve(null), + getCurrentUser: () => null, + hasPreviousSignIn: () => false, +}; + +export const statusCodes = { SIGN_IN_CANCELLED: 'SIGN_IN_CANCELLED', IN_PROGRESS: 'IN_PROGRESS', PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE' }; + +export default { GoogleSignin, statusCodes }; diff --git a/webpack.config.js b/webpack.config.js index d401c779..8d0040b5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -230,6 +230,7 @@ module.exports = (env = {}, argv = {}) => ({ 'react-native-device-info': path.resolve(__dirname, 'web/react-native-device-info.web.js'), 'react-native-fast-image': path.resolve(__dirname, 'web/react-native-fast-image.web.js'), '@react-native-community/blur': path.resolve(__dirname, 'web/react-native-community-blur.web.js'), + '@react-native-google-signin/google-signin': path.resolve(__dirname, 'web/react-native-google-signin.web.js'), '@fleetbase/storefront': path.resolve(__dirname, 'node_modules/@fleetbase/storefront/dist/esm/storefront.js'), '@fleetbase/sdk': path.resolve(__dirname, 'node_modules/@fleetbase/sdk/dist/esm/fleetbase.js'), },