From 97933449b0539089908b64981480269e0e86c4e0 Mon Sep 17 00:00:00 2001 From: Jan Ole Schmidt <jan.ole.schmidt@holi.team> Date: Wed, 26 Feb 2025 14:43:13 +0000 Subject: [PATCH] HOLI-10925: add unsubscribe confirmation screen --- apps/mobile/navigation/RootNavigator.tsx | 5 + apps/mobile/navigation/linkingConfig.ts | 1 + apps/mobile/navigation/routeName.ts | 1 + apps/web/pages/unsubscribe.tsx | 8 ++ core/i18n/locales/de.json | 5 + core/i18n/locales/en.json | 5 + core/screens/mail/Unsubscribe.tsx | 12 ++ core/screens/mail/Unsubscribe.web.tsx | 111 ++++++++++++++++++ core/screens/mail/components/EmailContent.tsx | 84 +++++++++++++ .../mail/components/EmailIllustration.tsx | 41 +++++++ .../hooks/useEmailSubscriptionAnimation.ts | 33 ++++++ core/screens/mail/mutations.tsx | 9 ++ core/screens/mail/utils/maskEmail.ts | 26 ++++ .../resubscribe_illustration.svg | 13 ++ .../unsubscribe_illustration.svg | 8 ++ 15 files changed, 362 insertions(+) create mode 100644 apps/web/pages/unsubscribe.tsx create mode 100644 core/screens/mail/Unsubscribe.tsx create mode 100644 core/screens/mail/Unsubscribe.web.tsx create mode 100644 core/screens/mail/components/EmailContent.tsx create mode 100644 core/screens/mail/components/EmailIllustration.tsx create mode 100644 core/screens/mail/hooks/useEmailSubscriptionAnimation.ts create mode 100644 core/screens/mail/mutations.tsx create mode 100644 core/screens/mail/utils/maskEmail.ts create mode 100644 packages/ui/assets/img/illustrations/resubscribe_illustration.svg create mode 100644 packages/ui/assets/img/illustrations/unsubscribe_illustration.svg diff --git a/apps/mobile/navigation/RootNavigator.tsx b/apps/mobile/navigation/RootNavigator.tsx index d2227b7744..3d3d1cebb9 100644 --- a/apps/mobile/navigation/RootNavigator.tsx +++ b/apps/mobile/navigation/RootNavigator.tsx @@ -33,6 +33,7 @@ import type { SearchParams as OldSearchParams } from '@holi/core/screens/search/ import UserProfile, { type UserProfileParams } from '@holi/core/screens/userprofile/UserProfile' import NotFound from '@holi/core/screens/404/NotFound' import { AuthSelection } from '@holi/core/auth/screens/AuthSelection' +import Unsubscribe from '@holi/core/screens/mail/Unsubscribe' export type RootStackScreens = { [RouteName.BottomTabs]: undefined @@ -67,6 +68,8 @@ export type RootStackScreens = { [RouteName.AuthSelection]: undefined + [RouteName.Unsubscribe]: undefined + [RouteName.Search]: SearchParams | OldSearchParams [RouteName.UserProfile]: UserProfileParams @@ -266,6 +269,8 @@ const RootNavigator = () => { headerLeft: () => <HeaderBackButton />, }} /> + + <RootStack.Screen name={RouteName.Unsubscribe} component={Unsubscribe} options={{ headerShown: true }} /> </RootStack.Navigator> ) } diff --git a/apps/mobile/navigation/linkingConfig.ts b/apps/mobile/navigation/linkingConfig.ts index b8d675400f..1ea20a8e35 100644 --- a/apps/mobile/navigation/linkingConfig.ts +++ b/apps/mobile/navigation/linkingConfig.ts @@ -182,6 +182,7 @@ const linkingConfig: LinkingOptions<RootStackScreens>['config'] = { [RouteName.UserProfile]: 'profile/:userId', [RouteName.NotFound]: '404', [RouteName.AuthSelection]: '/authSelection', + [RouteName.Unsubscribe]: 'unsubscribe', }, } diff --git a/apps/mobile/navigation/routeName.ts b/apps/mobile/navigation/routeName.ts index 0142ac9396..1e4b93775b 100644 --- a/apps/mobile/navigation/routeName.ts +++ b/apps/mobile/navigation/routeName.ts @@ -117,6 +117,7 @@ export enum RouteName { VolunteeringApp = 'VolunteeringApp', NotFound = 'NotFound', AuthSelection = 'AuthSelection', + Unsubscribe = 'Unsubscribe', // Onboarding Stack Onboarding = 'Onboarding', diff --git a/apps/web/pages/unsubscribe.tsx b/apps/web/pages/unsubscribe.tsx new file mode 100644 index 0000000000..691a955dcc --- /dev/null +++ b/apps/web/pages/unsubscribe.tsx @@ -0,0 +1,8 @@ +import type { NextPage } from 'next' +import React from 'react' + +import Unsubscribe from '@holi/core/screens/mail/Unsubscribe' + +const UnsubscribePage: NextPage = () => <Unsubscribe /> + +export default UnsubscribePage diff --git a/core/i18n/locales/de.json b/core/i18n/locales/de.json index e33b8c71eb..c11db9b807 100644 --- a/core/i18n/locales/de.json +++ b/core/i18n/locales/de.json @@ -214,6 +214,11 @@ "Freitag", "Samstag" ], + "email.unsubscribe.description": "Du hast dich erfolgreich als \"{{email}}\" bei {{series}} abgemeldet.", + "email.unsubscribe.welcomeSeries": "Begrüßungsserie", + "email.unsubscribe.subText": "Hast du dich versehentlich abgemeldet? Du kannst ganz einfach zum alten Zustand zurückkehren.", + "email.unsubscribe.button": "Melde dich wieder bei der Email-Serie an", + "email.resubscribe.description": "Du hast dich erfolgreich als \"{{email}}\" bei {{series}} angemeldet.", "error.errorBoundary.retry": "Erneut versuchen?", "favorites.noResults.description": "Tippe aufs Lesezeichen-Symbol, um Inhalte zu deinen Favoriten hinzuzufügen.", "favorites.noResults.title": "No keine Favoriten gespeichert", diff --git a/core/i18n/locales/en.json b/core/i18n/locales/en.json index f425c3dd4a..8e2dfde968 100644 --- a/core/i18n/locales/en.json +++ b/core/i18n/locales/en.json @@ -214,6 +214,11 @@ "Friday", "Saturday" ], + "email.unsubscribe.description": "You have successfully unsubscribed from the email {{series}} as \"{{email}}\".", + "email.unsubscribe.welcomeSeries": "welcome series", + "email.unsubscribe.subText": "Did you unsubscribe by mistake? You can easily go back to how it was.", + "email.unsubscribe.button": "Subscribe again to the email series", + "email.resubscribe.description": "You have successfully re-subscribed to the email {{series}} as \"{{email}}\".", "error.errorBoundary.retry": "Try again?", "favorites.noResults.description": "Tap the bookmark icon on content you like to add them to your favourites.", "favorites.noResults.title": "No favourites saved yet", diff --git a/core/screens/mail/Unsubscribe.tsx b/core/screens/mail/Unsubscribe.tsx new file mode 100644 index 0000000000..937a9f9fe1 --- /dev/null +++ b/core/screens/mail/Unsubscribe.tsx @@ -0,0 +1,12 @@ +import type React from 'react' + +export type UnsubscribeParams = { + token: string + email: string + series: string +} + +export const Unsubscribe: React.FC = () => { + return <></> +} +export default Unsubscribe diff --git a/core/screens/mail/Unsubscribe.web.tsx b/core/screens/mail/Unsubscribe.web.tsx new file mode 100644 index 0000000000..b4340495d6 --- /dev/null +++ b/core/screens/mail/Unsubscribe.web.tsx @@ -0,0 +1,111 @@ +import type React from 'react' +import { Platform, Pressable, StyleSheet, View } from 'react-native' +import useRouting from '@holi/core/navigation/hooks/useRouting' +import { HoliGap } from '@holi/ui/components/atoms/HoliGap' +import HoliLogo from '@holi/ui/components/atoms/HoliLogo' +import { useTheme, type HoliTheme } from '@holi/ui/styles/theme' +import Spacing from '@holi/ui/foundations/Spacing' +import { useSetScreenOptions } from '@holi/core/navigation/hooks/useScreenOptions' +import useIsomorphicLayoutEffect from '@holi/core/helpers/useIsomorphicLayoutEffect' +import { maskEmail } from '@holi/core/screens/mail/utils/maskEmail' +import { useEffect, useState } from 'react' +import { EmailIllustration } from './components/EmailIllustration' +import { EmailContent } from './components/EmailContent' +import { useEmailSubscriptionAnimation } from './hooks/useEmailSubscriptionAnimation' +import { unsubscribeFromNewsletterMutation } from '@holi/core/screens/mail/mutations' +import { useMutation } from '@apollo/client' +import createParamHooks from '@holi/core/navigation/hooks/useParam' + +export type UnsubscribeParams = { + token: string + email: string + series: string +} + +const { useParam } = createParamHooks<UnsubscribeParams>() + +export const Unsubscribe: React.FC = () => { + const [token] = useParam('token') + const [email] = useParam('email') + const [series] = useParam('series') + + const { navigate } = useRouting() + const { theme } = useTheme() + const setScreenOptions = useSetScreenOptions() + const [isResubscribed, setIsResubscribed] = useState(false) + const { fadeAnim, animateTransition } = useEmailSubscriptionAnimation() + const maskedEmail = email ? maskEmail(email as string) : '' + const styles = getStyles(theme) + + const [unsubscribe] = useMutation(unsubscribeFromNewsletterMutation, { + variables: { + token, + }, + }) + + useEffect(() => { + if (token) { + unsubscribe() + } + }, [unsubscribe, token]) + + useIsomorphicLayoutEffect(() => { + setScreenOptions({ + headerBackVisible: false, + headerShown: false, + headerStyle: { + backgroundColor: theme.colors.white200, + }, + }) + }, [setScreenOptions, theme.colors.white200]) + + const handleLoginPress = () => { + navigate('/login') + } + + const handleResubscribe = () => { + // TODO: Implement resubscribe functionality + + animateTransition(() => setIsResubscribed(true)) + } + + return ( + <View style={styles.contentContainer}> + <HoliGap size="xs" /> + + <Pressable onPress={() => navigate('/')}> + <View style={styles.logo}> + <HoliLogo color={theme.colors.black300} /> + </View> + </Pressable> + + <HoliGap size="l" /> + + <EmailIllustration isResubscribed={isResubscribed} fadeAnim={fadeAnim} /> + + <EmailContent + isResubscribed={isResubscribed} + fadeAnim={fadeAnim} + series={series || ''} + email={maskedEmail} + onResubscribe={handleResubscribe} + onLogin={handleLoginPress} + /> + </View> + ) +} + +const getStyles = (theme: HoliTheme) => + StyleSheet.create({ + contentContainer: { + flex: 1, + paddingHorizontal: Platform.OS === 'web' ? 0 : 24, + backgroundColor: theme.colors.white200, + paddingTop: Spacing['4xl'], + }, + logo: { + alignItems: 'flex-start', + }, + }) + +export default Unsubscribe diff --git a/core/screens/mail/components/EmailContent.tsx b/core/screens/mail/components/EmailContent.tsx new file mode 100644 index 0000000000..15a31bc551 --- /dev/null +++ b/core/screens/mail/components/EmailContent.tsx @@ -0,0 +1,84 @@ +import type React from 'react' +import { Animated, Pressable, StyleSheet } from 'react-native' +import { Text } from 'holi-bricks/components/text/Text' +import { Button } from 'holi-bricks/components/button/Button' +import { HoliGap } from '@holi/ui/components/atoms/HoliGap' +import { HoliIcon } from '@holi/icons/src/HoliIcon' +import { Refresh } from '@holi/icons/src/generated' +import Spacing from '@holi/ui/foundations/Spacing' +import { useTheme } from '@holi/ui/styles/theme' +import { useTranslation } from 'react-i18next' + +interface EmailContentProps { + isResubscribed: boolean + fadeAnim?: Animated.Value + series: string + email: string + onResubscribe: () => void + onLogin: () => void +} + +export const EmailContent: React.FC<EmailContentProps> = ({ + isResubscribed, + fadeAnim, + series, + email, + onResubscribe, + onLogin, +}) => { + const { theme } = useTheme() + const { t } = useTranslation() + + let fullSeriesName = series + + switch (series) { + case 'welcome': + fullSeriesName = t('email.unsubscribe.welcomeSeries') + break + } + + return ( + <Animated.View + style={{ + opacity: fadeAnim, + }} + > + <Text size="xxl" color="main" textAlign="left"> + {isResubscribed + ? t('email.resubscribe.description', { series: fullSeriesName, email }) + : t('email.unsubscribe.description', { series: fullSeriesName, email })} + </Text> + + <HoliGap size="ml" /> + + {!isResubscribed && ( + <> + <Text size="md" color="main" textAlign="left"> + {t('email.unsubscribe.subText')} + </Text> + + <HoliGap size="s" /> + + <Pressable onPress={onResubscribe} style={styles.resubscribeButton}> + <Text size="lg" color="informative" textAlign="left"> + {t('email.unsubscribe.button')} + </Text> + <HoliIcon icon={Refresh} size={24} color={theme.colors.brandB300} /> + </Pressable> + + <HoliGap size="ml" /> + </> + )} + + <Button size="lg" label="Log into holi" variant="primary" onPress={onLogin} /> + </Animated.View> + ) +} + +const styles = StyleSheet.create({ + resubscribeButton: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing['4xs'], + }, +}) diff --git a/core/screens/mail/components/EmailIllustration.tsx b/core/screens/mail/components/EmailIllustration.tsx new file mode 100644 index 0000000000..a4d33962bf --- /dev/null +++ b/core/screens/mail/components/EmailIllustration.tsx @@ -0,0 +1,41 @@ +import type React from 'react' +import { Animated, StyleSheet } from 'react-native' +import HoliLocalImage from '@holi/ui/components/atoms/HoliLocalImage' +import Spacing from '@holi/ui/foundations/Spacing' + +interface EmailIllustrationProps { + isResubscribed: boolean + fadeAnim?: Animated.Value +} + +export const EmailIllustration: React.FC<EmailIllustrationProps> = ({ isResubscribed, fadeAnim }) => { + return ( + <Animated.View + style={[ + styles.container, + { + opacity: fadeAnim, + }, + ]} + > + <HoliLocalImage + imageSource={ + isResubscribed + ? require('@holi/ui/assets/img/illustrations/resubscribe_illustration.svg') + : require('@holi/ui/assets/img/illustrations/unsubscribe_illustration.svg') + } + width={74} + height={72} + label={isResubscribed ? 'Email Resubscribe' : 'Email Unsubscribe'} + isSvg + /> + </Animated.View> + ) +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'flex-start', + marginBottom: Spacing['3xs'], + }, +}) diff --git a/core/screens/mail/hooks/useEmailSubscriptionAnimation.ts b/core/screens/mail/hooks/useEmailSubscriptionAnimation.ts new file mode 100644 index 0000000000..faeca2a4fa --- /dev/null +++ b/core/screens/mail/hooks/useEmailSubscriptionAnimation.ts @@ -0,0 +1,33 @@ +import { useRef } from 'react' +import { Animated } from 'react-native' + +interface AnimationValues { + fadeAnim: Animated.Value + animateTransition: (callback: () => void) => void +} + +export const useEmailSubscriptionAnimation = (): AnimationValues => { + const fadeAnim = useRef(new Animated.Value(1)).current + + const animateTransition = (callback: () => void) => { + // Fade out + Animated.timing(fadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }).start(() => { + callback() + // Fade in + Animated.timing(fadeAnim, { + toValue: 1, + duration: 150, + useNativeDriver: true, + }).start() + }) + } + + return { + fadeAnim, + animateTransition, + } +} diff --git a/core/screens/mail/mutations.tsx b/core/screens/mail/mutations.tsx new file mode 100644 index 0000000000..5595c712aa --- /dev/null +++ b/core/screens/mail/mutations.tsx @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client/core' + +export const unsubscribeFromNewsletterMutation = gql` + mutation UnsubscribeFromNewsletter($token: String!) { + unsubscribeFromNewsletter(token: $token) { + success + } + } +` diff --git a/core/screens/mail/utils/maskEmail.ts b/core/screens/mail/utils/maskEmail.ts new file mode 100644 index 0000000000..a35f9af264 --- /dev/null +++ b/core/screens/mail/utils/maskEmail.ts @@ -0,0 +1,26 @@ +/** + * Masks an email address for privacy by showing only the first few characters + * of the local part while keeping the domain visible. + * Examples: + * - "john@example.com" -> "joh*@example.com" + * - "a@example.com" -> "*@example.com" + * - "ab@example.com" -> "a*@example.com" + * - "abc@example.com" -> "a**@example.com" + * - 'johnDoe@doe.com' -> 'jo****@doe.com' + */ + +export const maskEmail = (email: string): string => { + const [localPart, domain] = email.split('@') + if (!domain) return email + + // If local part is less than 3 characters, mask at least 1 letter + const visiblePart = localPart.slice(0, 3) + const maskedLocalPart = + localPart.length === 1 + ? '*' + : localPart.length <= 3 + ? localPart[0] + '*'.repeat(localPart.length - 1) + : visiblePart + '*'.repeat(localPart.length - visiblePart.length) + + return `${maskedLocalPart}@${domain}` +} diff --git a/packages/ui/assets/img/illustrations/resubscribe_illustration.svg b/packages/ui/assets/img/illustrations/resubscribe_illustration.svg new file mode 100644 index 0000000000..6ebbc219fc --- /dev/null +++ b/packages/ui/assets/img/illustrations/resubscribe_illustration.svg @@ -0,0 +1,13 @@ +<svg width="74" height="72" viewBox="0 0 74 72" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M2 17C2 13.6863 4.68629 11 8 11L64 11C67.3137 11 70 13.6863 70 17V55C70 58.3137 67.3137 61 64 61H8C4.68629 61 2 58.3137 2 55L2 17Z" fill="white" stroke="#262424" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M67 12L39.0995 46.2007C37.4989 48.1627 34.5011 48.1627 32.9005 46.2007L5 12" stroke="#262424" stroke-linecap="round" stroke-linejoin="round"/> +<circle cx="65" cy="14" r="7" fill="#262424"/> +<g clip-path="url(#clip0_4001_14343)"> +<path d="M64.064 14.781L68.1881 10.6562L68.6004 11.0685L63.7104 15.9592L61.0584 13.3073L61.4708 12.895L63.3568 14.7811L63.7104 15.1346L64.064 14.781ZM56.083 13.4997C56.083 18.1482 59.8512 21.9163 64.4997 21.9163C69.1482 21.9163 72.9163 18.1482 72.9163 13.4997C72.9163 8.85116 69.1482 5.08301 64.4997 5.08301C59.8512 5.08301 56.083 8.85116 56.083 13.4997Z" fill="#D6FFC7" stroke="#262424"/> +</g> +<defs> +<clipPath id="clip0_4001_14343"> +<rect width="19" height="19" fill="white" transform="translate(55 4)"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui/assets/img/illustrations/unsubscribe_illustration.svg b/packages/ui/assets/img/illustrations/unsubscribe_illustration.svg new file mode 100644 index 0000000000..0932175b05 --- /dev/null +++ b/packages/ui/assets/img/illustrations/unsubscribe_illustration.svg @@ -0,0 +1,8 @@ +<svg width="74" height="72" viewBox="0 0 74 72" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M2 17C2 13.6863 4.68629 11 8 11L64 11C67.3137 11 70 13.6863 70 17V55C70 58.3137 67.3137 61 64 61H8C4.68629 61 2 58.3137 2 55L2 17Z" fill="white" stroke="#262424" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M67 12L39.0995 46.2007C37.4989 48.1627 34.5011 48.1627 32.9005 46.2007L5 12" stroke="#262424" stroke-linecap="round" stroke-linejoin="round"/> +<circle cx="65" cy="14" r="7" fill="#262424"/> +<circle cx="64.5" cy="13.5" r="9" fill="#D7F3FF" stroke="#262424"/> +<path d="M61.333 10.333L67.6663 16.6663" stroke="#262424" stroke-linecap="round"/> +<path d="M67.667 10.333L61.3337 16.6663" stroke="#262424" stroke-linecap="round"/> +</svg> -- GitLab