diff --git a/packages/mobile/src/features/common/alerts/AppDialog.tsx b/packages/mobile/src/features/common/alerts/AppDialog.tsx index 18b5dd04..a67175bb 100644 --- a/packages/mobile/src/features/common/alerts/AppDialog.tsx +++ b/packages/mobile/src/features/common/alerts/AppDialog.tsx @@ -146,6 +146,27 @@ export default function AppDialog({ {rendered.title} {rendered.message ? {rendered.message} : null} + {rendered.link ? ( + { + rendered.link?.onPress(); + close(); + }} + accessibilityRole="link" + accessibilityLabel={rendered.link.label} + style={({ pressed }) => [ + styles.linkChip, + { backgroundColor: v.badgeBg }, + pressed && { opacity: 0.75, transform: [{ scale: 0.97 }] }, + ]} + > + + + {rendered.link.label} + + + ) : null} + {rendered.buttons.map((button, i) => { const isCancel = button.style === 'cancel'; @@ -211,6 +232,17 @@ const styles = StyleSheet.create({ sparkle: { position: 'absolute', width: 8, height: 8, borderRadius: 4 }, title: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, textAlign: 'center', letterSpacing: -0.2 }, message: { fontSize: 14, color: colors.textSecondary, textAlign: 'center', marginTop: 8, lineHeight: 20 }, + linkChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 14, + paddingHorizontal: 16, + height: 38, + maxWidth: '100%', + borderRadius: radii.pill, + }, + linkChipText: { fontSize: 14, fontWeight: '700', flexShrink: 1 }, buttonRow: { flexDirection: 'row', gap: 10, marginTop: 22, width: '100%' }, buttonRowSingle: { justifyContent: 'center' }, button: { flex: 1, height: 48, borderRadius: radii.md, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 12 }, diff --git a/packages/mobile/src/features/common/alerts/appAlert.ts b/packages/mobile/src/features/common/alerts/appAlert.ts index e3c62a08..ab8ca67e 100644 --- a/packages/mobile/src/features/common/alerts/appAlert.ts +++ b/packages/mobile/src/features/common/alerts/appAlert.ts @@ -42,6 +42,7 @@ export function showAppAlert( variant: options?.variant ?? 'info', cancelable: options?.cancelable ?? true, icon: options?.icon, + link: options?.link, onDismiss: options?.onDismiss, }); } diff --git a/packages/mobile/src/features/common/alerts/types.ts b/packages/mobile/src/features/common/alerts/types.ts index 210de11b..84e47466 100644 --- a/packages/mobile/src/features/common/alerts/types.ts +++ b/packages/mobile/src/features/common/alerts/types.ts @@ -11,11 +11,18 @@ export interface AlertButton { onPress?: () => void; } +/** Tappable link chip rendered between the dialog message and its buttons. */ +export interface DialogLink { + label: string; + onPress: () => void; +} + export interface AlertOptions { variant?: DialogVariant; /** Backdrop tap / Android back dismiss. Defaults to true, matching Alert.alert. */ cancelable?: boolean; icon?: IoniconName; + link?: DialogLink; onDismiss?: () => void; } @@ -34,6 +41,7 @@ export interface QueuedDialog { variant: DialogVariant; cancelable: boolean; icon?: IoniconName; + link?: DialogLink; onDismiss?: () => void; } diff --git a/packages/mobile/src/features/common/external-link.ts b/packages/mobile/src/features/common/external-link.ts new file mode 100644 index 00000000..2794406a --- /dev/null +++ b/packages/mobile/src/features/common/external-link.ts @@ -0,0 +1,105 @@ +import { Linking } from 'react-native'; +import * as WebBrowser from 'expo-web-browser'; +import { showAppAlert } from './alerts/appAlert'; +import { colors } from './tokens'; +import type { IoniconName } from './alerts/types'; + +/** Human display form of a URL for link chips: no protocol, no trailing slash. */ +export function displayUrl(url: string): string { + return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); +} + +/** + * Open a web page in the in-app browser sheet so the user never leaves the + * app. Falls back to the system browser, then to an info dialog with the URL. + */ +export async function openInAppBrowser(url: string): Promise { + try { + await WebBrowser.openBrowserAsync(url, { + controlsColor: colors.brandBlue, + toolbarColor: colors.cardBg, + presentationStyle: WebBrowser.WebBrowserPresentationStyle.PAGE_SHEET, + }); + } catch { + try { + await Linking.openURL(url); + } catch { + showAppAlert('Unable to open page', `Visit ${displayUrl(url)} in your browser.`, undefined, { + variant: 'info', + icon: 'globe-outline', + }); + } + } +} + +/** + * Confirm-before-leaving popup for web links: shows the destination as a + * tappable chip plus an "Open page" button. Nothing opens until the user + * explicitly chooses to. + */ +export function confirmWebLink({ + title, + message, + url, + icon, +}: { + title: string; + message: string; + url: string; + icon?: IoniconName; +}): void { + showAppAlert( + title, + message, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Open page', onPress: () => void openInAppBrowser(url) }, + ], + { + variant: 'info', + icon, + link: { label: displayUrl(url), onPress: () => void openInAppBrowser(url) }, + }, + ); +} + +/** + * Confirm-before-leaving popup for email links: shows the address as a + * tappable chip plus an "Open Mail" button that launches the mail composer. + */ +export function confirmEmail({ + title, + message, + email, + subject, + icon, +}: { + title: string; + message: string; + email: string; + subject?: string; + icon?: IoniconName; +}): void { + const mailto = `mailto:${email}${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`; + const openMail = () => { + Linking.openURL(mailto).catch(() => { + showAppAlert('Unable to open mail app', `Email us at ${email}`, undefined, { + variant: 'info', + icon: icon ?? 'mail-outline', + }); + }); + }; + showAppAlert( + title, + message, + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Open Mail', onPress: openMail }, + ], + { + variant: 'info', + icon, + link: { label: email, onPress: openMail }, + }, + ); +} diff --git a/packages/mobile/src/features/profile/ProfileScreen.tsx b/packages/mobile/src/features/profile/ProfileScreen.tsx index 6bce90ab..85f0cff4 100644 --- a/packages/mobile/src/features/profile/ProfileScreen.tsx +++ b/packages/mobile/src/features/profile/ProfileScreen.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useState } from 'react'; import { - Linking, Pressable, ScrollView, StyleSheet, @@ -16,6 +15,7 @@ import { useFocusEffect, useRouter } from 'expo-router'; import { useAuth } from '../../lib/AuthContext'; import apiClient from '../../lib/api-client'; import { showAppAlert } from '../common/alerts/appAlert'; +import { confirmEmail, confirmWebLink, openInAppBrowser } from '../common/external-link'; import { Skeleton } from '../common/Skeleton'; import { colors, typography, radii, shadow, gradients, alpha } from '../common/tokens'; @@ -108,22 +108,37 @@ export default function ProfileScreen() { }; const openSupport = () => { - Linking.openURL('mailto:support@rayhealthevv.com?subject=RayHealthEVV%20Support').catch(() => { - showAppAlert('Support', 'Email us at support@rayhealthevv.com', undefined, { variant: 'info', icon: 'help-buoy-outline' }); + confirmEmail({ + title: 'Contact Support', + message: 'Our support team is ready to help. Reach us by email and we will get back to you.', + email: 'support@rayhealthevv.com', + subject: 'RayHealthEVV Support', + icon: 'help-buoy-outline', }); }; const openPrivacyPolicy = () => { - Linking.openURL('https://rayhealthevv.com/privacy').catch(() => { - showAppAlert( - 'Privacy policy', - 'Visit https://rayhealthevv.com/privacy in your browser.', - undefined, - { variant: 'info', icon: 'shield-checkmark-outline' }, - ); + confirmWebLink({ + title: 'Privacy policy', + message: 'Our privacy policy is available on the RayHealthEVV website. It explains how we protect and handle your data.', + url: 'https://rayhealthevv.com/privacy', + icon: 'shield-checkmark-outline', }); }; + const showAbout = () => { + showAppAlert( + 'RayHealthEVV', + `Version ${version}. Electronic Visit Verification for home care, built for Pennsylvania agencies and caregivers. Learn more on our website.`, + [{ text: 'Close', style: 'cancel' }], + { + variant: 'info', + icon: 'information-circle-outline', + link: { label: 'rayhealthevv.com', onPress: () => void openInAppBrowser('https://rayhealthevv.com') }, + }, + ); + }; + return ( @@ -224,6 +239,7 @@ export default function ProfileScreen() { tint={colors.slate} title="About" value={`v${version}`} + onPress={showAbout} isLast />