Skip to content

Commit

Permalink
feat(mobile): add paywall ui (#264)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkdev98 committed Sep 3, 2024
1 parent a66c0ff commit 02b9a4e
Show file tree
Hide file tree
Showing 10 changed files with 377 additions and 17 deletions.
24 changes: 13 additions & 11 deletions apps/mobile/app/(app)/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,19 @@ export default function SettingsScreen() {
className="bg-card"
>
<ProfileCard />
<Button className="!px-4 !h-14 mx-6 justify-between">
<View>
<Text className="!text-base font-semibold">
{t(i18n)`Get 6pm Pro`}
</Text>
<Text className="!text-xs font-medium opacity-65">
{t(i18n)`Unlocks full AI power and more!`}
</Text>
</View>
<LockKeyholeIcon className="h-6 w-6 text-muted-foreground" />
</Button>
<Link href="/paywall" asChild>
<Button className="!px-4 !h-14 mx-6 justify-between">
<View>
<Text className="!text-base font-semibold">
{t(i18n)`Get 6pm Pro`}
</Text>
<Text className="!text-xs font-medium opacity-65">
{t(i18n)`Unlocks full AI power and more!`}
</Text>
</View>
<LockKeyholeIcon className="h-6 w-6 text-muted-foreground" />
</Button>
</Link>
<View className="mt-4 gap-2">
<Text className="mx-6 font-sans text-muted-foreground">
{t(i18n)`General`}
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export default function AuthenticatedLayout() {
headerTitle: t(i18n)`Feedback`,
}}
/>
<Stack.Screen
name="paywall"
options={{
presentation: 'modal',
headerTitle: '',
}}
/>
<Stack.Screen
name="appearance"
options={{ headerTitle: t(i18n)`Appearance` }}
Expand Down
173 changes: 173 additions & 0 deletions apps/mobile/app/(app)/paywall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { AmountFormat } from '@/components/common/amount-format'
import { Marquee } from '@/components/common/marquee'
import { PaywallIllustration } from '@/components/svg-assets/paywall-illustration'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Text } from '@/components/ui/text'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { BlurView } from 'expo-blur'
import { Link } from 'expo-router'
import { CheckCircleIcon } from 'lucide-react-native'
import { cssInterop } from 'nativewind'
import { useState } from 'react'
import {
Image,
type ImageSourcePropType,
Pressable,
ScrollView,
View,
} from 'react-native'

cssInterop(BlurView, {
className: {
target: 'style',
},
})

type FeatureCardProps = {
source: ImageSourcePropType
title: string
className?: string
}
function FeatureCard({ source, title, className }: FeatureCardProps) {
return (
<View className={className}>
<Image
source={source}
className="h-32 w-44 rounded-xl bg-primary dark:bg-muted"
resizeMode="contain"
/>
<BlurView
intensity={20}
className="absolute right-2 bottom-2 left-2 overflow-hidden rounded-lg p-2 dark:border dark:border-white/10"
>
<Text className="text-center font-medium text-primary-foreground text-sm dark:text-primary">
{title}
</Text>
</BlurView>
</View>
)
}

export default function PaywallScreen() {
const { i18n } = useLingui()
const [plan, setPlan] = useState<'growth' | 'wealth'>('growth')

const proFeatures = [
{
source: require('../../assets/images/paywall-images/2.png'),
title: t(i18n)`Unlimited`,
},
{
source: require('../../assets/images/paywall-images/3.png'),
title: t(i18n)`AI Insights`,
},
{
source: require('../../assets/images/paywall-images/4.png'),
title: t(i18n)`Multi-currencies`,
},
{
source: require('../../assets/images/paywall-images/1.png'),
title: t(i18n)`Security`,
},
]

const plans = {
growth: [
t(i18n)`Up to 10 AI transactions per day`,
t(i18n)`Unlimited transactions & categories`,
t(i18n)`6 budgets & 6 accounts`,
t(i18n)`Multi-currencies & customizations`,
],
wealth: [
t(i18n)`Up to 25 AI transactions per day`,
t(i18n)`All features of growth plan`,
t(i18n)`Unlimited budgets & accounts`,
t(i18n)`Most advanced financial AI assistant`,
],
}

return (
<ScrollView
className="bg-card"
contentContainerClassName="gap-3"
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
<Text className="mx-8 my-2 font-sans font-semibold text-3xl text-primary">
{t(i18n)`Complete control over your finances`}
</Text>
<Marquee spacing={20} speed={0.5}>
<View className="flex-row gap-4 py-8">
{proFeatures.map((item, index) => (
<FeatureCard
key={item.title}
source={item.source}
title={item.title}
className={index % 2 === 0 ? '-translate-y-4' : 'translate-y-4'}
/>
))}
</View>
</Marquee>
<Tabs
value={plan}
className="mx-8"
onValueChange={(value) => {
setPlan(value as 'growth' | 'wealth')
}}
>
<TabsList>
<TabsTrigger value="growth">
<Text>{t(i18n)`Growth`}</Text>
</TabsTrigger>
<TabsTrigger value="wealth">
<Text>{t(i18n)`Wealth`}</Text>
</TabsTrigger>
</TabsList>
</Tabs>
<View className="gap-3 py-2">
{plans[plan].map((item) => (
<View className="mx-8 flex-row gap-3" key={item}>
<CheckCircleIcon className="size-6 text-amount-positive" />
<Text className="text-primary">{item}</Text>
</View>
))}
</View>
<View className="mx-8 flex-row items-end gap-6">
<Pressable className="h-36 flex-1 rounded-lg border-2 border-border">
<View className="flex-1 items-center justify-center bg-muted/40 p-4">
<Text className="font-bold text-4xl">1</Text>
<Text className="mb-4 text-center text-muted-foreground text-sm uppercase">
month
</Text>
<AmountFormat amount={99000} currency="VND" />
</View>
</Pressable>
<Pressable className="h-44 flex-1 overflow-hidden rounded-lg bg-primary p-0.5">
<View className="h-8 items-center justify-center bg-primary">
<Text className="text-center font-semibold text-primary-foreground text-sm uppercase">
{t(i18n)`Best value`}
</Text>
</View>
<View className="flex-1 items-center justify-center rounded-md bg-background p-4">
<Text className="font-bold text-4xl">12</Text>
<Text className="mb-4 text-center text-muted-foreground text-sm uppercase">
months
</Text>
<AmountFormat amount={999000} currency="VND" />
</View>
</Pressable>
</View>
<Button className="mx-8 mt-2">
<Text>{t(i18n)`Unlock 6pm Pro`}</Text>
</Button>
<Link href="/privacy-policy">
<Text className="mx-auto text-center text-muted-foreground text-sm">
Privacy Policy
</Text>
</Link>
<PaywallIllustration className="mx-auto h-[566px] w-[200px] text-primary" />
</ScrollView>
)
}
Binary file added apps/mobile/assets/images/paywall-images/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/images/paywall-images/2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/images/paywall-images/3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/images/paywall-images/4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 129 additions & 0 deletions apps/mobile/components/common/marquee.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import * as React from 'react'
import type { ViewStyle } from 'react-native'
import { StyleSheet } from 'react-native'
import { View } from 'react-native'
import type { SharedValue } from 'react-native-reanimated'
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
} from 'react-native-reanimated'

const AnimatedChild = ({
index,
children,
anim,
textWidth,
spacing,
}: React.PropsWithChildren<{
index: number
anim: SharedValue<number>
textWidth: SharedValue<number>
spacing: number
}>) => {
const stylez = useAnimatedStyle(() => {
return {
position: 'absolute',
left: index * (textWidth.value + spacing),
transform: [
{
translateX: -(anim.value % (textWidth.value + spacing)),
},
],
}
}, [index, spacing, textWidth])
return <Animated.View style={stylez}>{children}</Animated.View>
}

export type MarqueeProps = React.PropsWithChildren<{
speed?: number
spacing?: number
style?: ViewStyle
}>

/**
* Used to animate the given children in a horizontal manner.
*/
export const Marquee = React.memo(
({ speed = 1, children, spacing = 0, style }: MarqueeProps) => {
const parentWidth = useSharedValue(0)
const textWidth = useSharedValue(0)
const [cloneTimes, setCloneTimes] = React.useState(0)
const anim = useSharedValue(0)

useFrameCallback(() => {
anim.value += speed
}, true)

useAnimatedReaction(
() => {
if (textWidth.value === 0 || parentWidth.value === 0) {
return 0
}
return Math.round(parentWidth.value / textWidth.value) + 1
},
(v) => {
if (v === 0) {
return
}
// This is going to cover the case when the text/element size
// is greater than the actual parent size
// Double this to cover the entire screen twice, in this way we can
// reset the position of the first element when its going to move out
// of the screen without any noticible glitch
runOnJS(setCloneTimes)(v * 2)
},
[],
)
return (
<Animated.View
style={style}
onLayout={(ev) => {
parentWidth.value = ev.nativeEvent.layout.width
}}
pointerEvents="box-none"
>
<Animated.View style={styles.row} pointerEvents="box-none">
{
// We are adding the text inside a ScrollView because in this way we
// ensure that its not going to "wrap".
}
<Animated.ScrollView
horizontal
style={styles.hidden}
pointerEvents="box-none"
>
<View
onLayout={(ev) => {
textWidth.value = ev.nativeEvent.layout.width
}}
>
{children}
</View>
</Animated.ScrollView>
{cloneTimes > 0 &&
[...Array(cloneTimes).keys()].map((index) => {
return (
<AnimatedChild
key={`clone-${index}`}
index={index}
anim={anim}
textWidth={textWidth}
spacing={spacing}
>
{children}
</AnimatedChild>
)
})}
</Animated.View>
</Animated.View>
)
},
)

const styles = StyleSheet.create({
hidden: { opacity: 0, zIndex: -9999 },
row: { flexDirection: 'row', overflow: 'hidden' },
})
49 changes: 49 additions & 0 deletions apps/mobile/components/svg-assets/paywall-illustration.tsx

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions apps/mobile/components/ui/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
'h-10 flex-row items-center justify-center rounded-md bg-muted p-1 web:inline-flex native:h-11 native:px-1.5',
'web:inline-flex h-10 native:h-11 flex-row items-center justify-center rounded-md bg-muted p-1 native:px-1.5',
className,
)}
{...props}
Expand All @@ -29,16 +29,16 @@ const TabsTrigger = React.forwardRef<
<TextClassContext.Provider
value={cn(
'text-sm native:text-base font-medium text-muted-foreground web:transition-all',
value === props.value && 'text-foreground',
value === props.value && 'text-foreground dark:text-primary',
)}
>
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex flex-1 flex-row items-center justify-center gap-3 rounded-sm px-3 py-1.5 font-medium text-sm shadow-none web:whitespace-nowrap web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2 web:ring-offset-background web:transition-all',
props.disabled && 'opacity-50 web:pointer-events-none',
'inline-flex flex-1 flex-row items-center justify-center gap-3 web:whitespace-nowrap rounded-sm px-3 py-1.5 font-medium text-sm shadow-none web:ring-offset-background web:transition-all web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
props.disabled && 'web:pointer-events-none opacity-50',
props.value === value &&
'bg-background shadow-sm shadow-border shadow-foreground/15',
'bg-background shadow-border shadow-sm dark:shadow-foreground/15',
className,
)}
{...props}
Expand All @@ -55,7 +55,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
'web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2 web:ring-offset-background',
'web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2',
className,
)}
{...props}
Expand Down

0 comments on commit 02b9a4e

Please sign in to comment.