Skip to content

Commit abca395

Browse files
authored
Free trial (#142)
* add free trial page * use existing licenseStore * update license api to consider free trial * update paywall dialog feature list * create shared paywall dialog store * use new paywall dialog store to open dialg * Checkout call to action hooked up * brighter savings text * add manage biliing to user profile * show lifetime access * fix user profile setting type issues * add confetti on sucessfull purchase * Give user's early access * minor adjustments * minor date fixes
1 parent 6a1ce88 commit abca395

27 files changed

Lines changed: 670 additions & 306 deletions

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
88
import { useInitializeAppState } from './hooks/useInitializeAppState'
99
import { SpotifyConfetti } from '@/components/SpotifyConfetti'
1010
import { useSpotifyEasterEgg } from '@/hooks/useSpotifyEasterEgg'
11+
import { PaywallDialog } from '@/components/PaywallDialog'
1112

1213
const queryClient = new QueryClient({
1314
defaultOptions: {
@@ -38,6 +39,7 @@ const App = () => {
3839
<TooltipProvider>
3940
<AppRouterWrapper />
4041
<Toaster position="bottom-right" richColors />
42+
<PaywallDialog />
4143
</TooltipProvider>
4244
</ThemeProvider>
4345
</QueryClientProvider>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { licenseApi } from '@/api/ebbApi/licenseApi'
3+
4+
describe('licenseApi', () => {
5+
describe('calculatePermissions', () => {
6+
it('should return null if the license is null', () => {
7+
const permissions = licenseApi.calculatePermissions(null)
8+
expect(permissions).toBeNull()
9+
})
10+
it('should return hasProAccess is false if the license is expired', () => {
11+
const permissions = licenseApi.calculatePermissions({
12+
id: '123',
13+
userId: '123',
14+
status: 'expired',
15+
licenseType: 'perpetual',
16+
purchaseDate: new Date(),
17+
expirationDate: new Date(),
18+
createdAt: new Date(),
19+
updatedAt: new Date(),
20+
})
21+
expect(permissions?.hasProAccess).toBe(false)
22+
})
23+
it('should return hasProAccess is true if the license is active', () => {
24+
const permissions = licenseApi.calculatePermissions({
25+
id: '123',
26+
userId: '123',
27+
status: 'active',
28+
licenseType: 'perpetual',
29+
purchaseDate: new Date(),
30+
expirationDate: new Date(),
31+
createdAt: new Date(),
32+
updatedAt: new Date(),
33+
})
34+
expect(permissions?.hasProAccess).toBe(true)
35+
})
36+
it('should return hasProAccess is true if the license is a free trial and not expired', () => {
37+
const permissions = licenseApi.calculatePermissions({
38+
id: '123',
39+
userId: '123',
40+
status: 'active',
41+
licenseType: 'free_trial',
42+
purchaseDate: new Date(),
43+
expirationDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
44+
createdAt: new Date(),
45+
updatedAt: new Date(),
46+
})
47+
expect(permissions?.hasProAccess).toBe(true)
48+
})
49+
it('should return hasProAccess is false if the license is a free trial and expired', () => {
50+
const permissions = licenseApi.calculatePermissions({
51+
id: '123',
52+
userId: '123',
53+
status: 'active',
54+
licenseType: 'free_trial',
55+
purchaseDate: new Date(),
56+
expirationDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)),
57+
createdAt: new Date(),
58+
updatedAt: new Date(),
59+
})
60+
expect(permissions?.hasProAccess).toBe(false)
61+
})
62+
})
63+
})

src/api/ebbApi/deviceApi.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,11 @@ export interface Device {
99

1010
export interface DeviceInfo {
1111
devices: Device[]
12-
maxDevices: number
1312
isDeviceLimitReached: boolean
1413
}
1514

1615
export const defaultDeviceInfo: DeviceInfo = {
1716
devices: [],
18-
maxDevices: 1,
1917
isDeviceLimitReached: false,
2018
}
2119

@@ -63,7 +61,6 @@ const registerDevice = async (userId: string, maxDevices: number): Promise<Devic
6361
if (deviceCount > maxDevices) {
6462
return {
6563
devices: existingDevices,
66-
maxDevices,
6764
isDeviceLimitReached: true,
6865
}
6966
}
@@ -79,7 +76,6 @@ const registerDevice = async (userId: string, maxDevices: number): Promise<Devic
7976

8077
return {
8178
devices: existingDevices,
82-
maxDevices,
8379
isDeviceLimitReached: false,
8480
}
8581

src/api/ebbApi/licenseApi.ts

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { PostgrestError } from '@supabase/supabase-js'
22
import { licenseRepo } from '@/db/supabase/licenseRepo'
33
import { DeviceInfo } from '@/api/ebbApi/deviceApi'
4+
import { platformApiRequest } from '../platformRequest'
5+
import { DateTime } from 'luxon'
46

57
export type LicenseStatus = 'active' | 'expired'
6-
export type LicenseType = 'perpetual' | 'subscription'
8+
export type LicenseType = 'perpetual' | 'subscription' | 'free_trial'
79
export interface RawLicense {
810
id: string
911
status: string
10-
license_type: 'perpetual' | 'subscription'
12+
license_type: LicenseType
1113
expiration_date: string | null
1214
}
1315
export interface License {
@@ -34,12 +36,9 @@ export interface LicenseDevice {
3436
export interface LicensePermissions {
3537
canUseHardDifficulty: boolean
3638
canUseAllowList: boolean
37-
canUseTypewriter: boolean
3839
canUseMultipleProfiles: boolean
3940
canUseMultipleDevices: boolean
4041
hasProAccess: boolean
41-
maxDevices: number
42-
isUpdateEligible: boolean
4342
}
4443

4544
export type LicenseInfo = {
@@ -51,12 +50,9 @@ export type LicenseInfo = {
5150
export const defaultPermissions: LicensePermissions = {
5251
canUseHardDifficulty: false,
5352
canUseAllowList: false,
54-
canUseTypewriter: false,
5553
canUseMultipleProfiles: false,
5654
canUseMultipleDevices: false,
5755
hasProAccess: false,
58-
maxDevices: 1,
59-
isUpdateEligible: false,
6056
}
6157

6258
const transformLicense = (rawLicense: RawLicense | null): License | null => {
@@ -77,25 +73,18 @@ const transformLicense = (rawLicense: RawLicense | null): License | null => {
7773

7874
const calculatePermissions = (license: License | null): LicensePermissions | null => {
7975
if (!license) return null
80-
const hasProAccess = license?.status === 'active'
76+
let hasProAccess = license?.status === 'active'
8177

82-
const isUpdateEligible = hasProAccess && license && (
83-
license.licenseType === 'subscription' ||
84-
(license.licenseType === 'perpetual' &&
85-
!!license.expirationDate &&
86-
license.expirationDate > new Date()
87-
)
88-
)
78+
if (license.licenseType === 'free_trial' && DateTime.fromJSDate(license.expirationDate) < DateTime.now()) {
79+
hasProAccess = false
80+
}
8981

9082
return {
9183
canUseHardDifficulty: hasProAccess,
9284
canUseAllowList: hasProAccess,
93-
canUseTypewriter: hasProAccess,
9485
canUseMultipleProfiles: hasProAccess,
9586
canUseMultipleDevices: hasProAccess,
9687
hasProAccess,
97-
maxDevices: hasProAccess ? 3 : 1,
98-
isUpdateEligible,
9988
}
10089
}
10190

@@ -105,16 +94,48 @@ const getLicenseInfo = async (userId: string): Promise<{data: LicenseInfo, error
10594
const permissions = calculatePermissions(license) || defaultPermissions
10695
const deviceInfo: DeviceInfo = {
10796
devices: [],
108-
maxDevices: permissions.maxDevices,
10997
isDeviceLimitReached: false,
11098
}
11199

112100
return {data: {license, permissions, deviceInfo }, error}
113101
}
114102

103+
export interface StartTrialResponse {
104+
success: boolean
105+
message: string
106+
}
107+
108+
export interface CancelLicenseResponse {
109+
success: boolean
110+
message: string
111+
data?: {
112+
license_id: number
113+
stripe_subscription_id: string
114+
canceled_at: number
115+
cancel_at_period_end: boolean
116+
}
117+
}
118+
119+
const startTrial = async (): Promise<StartTrialResponse> => {
120+
const response = await platformApiRequest({
121+
url: '/api/license/start-trial',
122+
method: 'POST'
123+
})
124+
return response as StartTrialResponse
125+
}
126+
127+
const cancelLicense = async (): Promise<CancelLicenseResponse> => {
128+
const response = await platformApiRequest({
129+
url: '/api/license/cancel',
130+
method: 'POST'
131+
})
132+
return response as CancelLicenseResponse
133+
}
115134

116135
export const licenseApi = {
117136
getLicenseInfo,
118137
calculatePermissions,
138+
startTrial,
139+
cancelLicense,
119140
}
120141

src/api/hooks/useDevice.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function useLogoutDevice() {
6666
})
6767
}
6868

69-
export function useRegisterDevice(userId: string, maxDevices: number) {
69+
export function useRegisterDevice(userId: string, maxDevices = 1) {
7070
return useQuery({
7171
queryKey: deviceKeys.registration(userId, maxDevices),
7272
queryFn: () => DeviceApi.registerDevice(userId, maxDevices),

src/api/hooks/useLicense.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useQuery } from '@tanstack/react-query'
1+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
22
import { defaultPermissions, License, licenseApi, LicensePermissions } from '@/api/ebbApi/licenseApi'
33
import { useRegisterDevice } from '@/api/hooks/useDevice'
44
import { defaultDeviceInfo, DeviceInfo } from '@/api/ebbApi/deviceApi'
@@ -35,8 +35,7 @@ export function useGetLicenseInfo(userId: string | null) {
3535
export function useLicenseWithDevices(userId: string | null) {
3636
const { data: licenseData, isLoading: licenseLoading, error: licenseError } = useGetLicenseInfo(userId)
3737
const { data: deviceInfo, isLoading: deviceLoading, error: deviceError } = useRegisterDevice(
38-
userId || '',
39-
licenseData?.permissions.maxDevices || 1
38+
userId || '',
4039
)
4140

4241
const isLoading = licenseLoading || deviceLoading
@@ -53,4 +52,29 @@ export function useLicenseWithDevices(userId: string | null) {
5352
isLoading,
5453
error,
5554
}
56-
}
55+
}
56+
57+
export function useStartTrial() {
58+
const queryClient = useQueryClient()
59+
60+
return useMutation({
61+
mutationFn: () => licenseApi.startTrial(),
62+
onSuccess: () => {
63+
// Invalidate license queries to refetch updated license info
64+
queryClient.invalidateQueries({ queryKey: licenseKeys.all })
65+
},
66+
})
67+
}
68+
69+
export function useCancelLicense() {
70+
const queryClient = useQueryClient()
71+
72+
return useMutation({
73+
mutationFn: () => licenseApi.cancelLicense(),
74+
onSuccess: () => {
75+
// Invalidate license queries to refetch updated license info
76+
queryClient.invalidateQueries({ queryKey: licenseKeys.all })
77+
},
78+
})
79+
}
80+

src/components/AppSelector.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { AppIcon } from '@/components/AppIcon'
99
import { AnalyticsButton } from '@/components/ui/analytics-button'
1010
import { DifficultySelector } from '@/components/difficulty-selector'
1111
import { CategoryTooltip } from '@/components/CategoryTooltip'
12-
import { PaywallDialog } from '@/components/PaywallDialog'
1312
import { usePermissions } from '@/hooks/usePermissions'
13+
import { usePaywall } from '@/hooks/usePaywall'
1414

1515
interface CategoryOption {
1616
type: 'category'
@@ -110,6 +110,7 @@ export function AppSelector({
110110
onDifficultyChange
111111
}: AppSelectorProps) {
112112
const { canUseAllowList, canUseHardDifficulty } = usePermissions()
113+
const { openPaywall } = usePaywall()
113114
const [open, setOpen] = useState(false)
114115
const [search, setSearch] = useState('')
115116
const inputRef = useRef<HTMLDivElement>(null)
@@ -535,23 +536,22 @@ export function AppSelector({
535536
</AnalyticsButton>
536537

537538
{!canUseAllowList ? (
538-
<PaywallDialog>
539-
<AnalyticsButton
540-
variant="ghost"
541-
size="sm"
542-
className={cn(
543-
'h-6 px-2 text-xs text-muted-foreground/80 hover:text-foreground',
544-
isAllowList && 'bg-muted/50'
545-
)}
546-
analyticsEvent="allow_list_clicked"
547-
analyticsProperties={{
548-
context: 'allow_list',
549-
button_location: 'app_selector'
550-
}}
551-
>
552-
Allow
553-
</AnalyticsButton>
554-
</PaywallDialog>
539+
<AnalyticsButton
540+
variant="ghost"
541+
size="sm"
542+
className={cn(
543+
'h-6 px-2 text-xs text-muted-foreground/80 hover:text-foreground',
544+
isAllowList && 'bg-muted/50'
545+
)}
546+
analyticsEvent="allow_list_clicked"
547+
analyticsProperties={{
548+
context: 'allow_list',
549+
button_location: 'app_selector'
550+
}}
551+
onClick={openPaywall}
552+
>
553+
Allow
554+
</AnalyticsButton>
555555
) : (
556556
<AnalyticsButton
557557
variant="ghost"

0 commit comments

Comments
 (0)