Skip to content

Commit 03f7d8c

Browse files
committed
refactor(unsubscribe): migrate page to React Query
Replace the hand-rolled useState+useEffect+requestJson server-state in the unsubscribe page with React Query hooks. Add useUnsubscribe (validation/load query, keyed by email+token, auto-runs on mount via enabled) and useUnsubscribeMutation (unsubscribe action, reconciles cached preferences on success) in hooks/queries/unsubscribe.ts with a hierarchical key factory. Export UnsubscribeData/UnsubscribeActionResponse/UnsubscribeType type aliases from the existing user contract; loading/error/success now derive from the query and mutation objects with no local server-state mirror.
1 parent edb2fe8 commit 03f7d8c

3 files changed

Lines changed: 103 additions & 75 deletions

File tree

apps/sim/app/unsubscribe/unsubscribe.tsx

Lines changed: 21 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,38 @@
11
'use client'
22

3-
import { Suspense, useEffect, useState } from 'react'
3+
import { Suspense } from 'react'
44
import { getErrorMessage } from '@sim/utils/errors'
55
import { useSearchParams } from 'next/navigation'
66
import { Loader } from '@/components/emcn'
7-
import { requestJson } from '@/lib/api/client/request'
8-
import type { ContractJsonResponse } from '@/lib/api/contracts'
9-
import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user'
7+
import type { UnsubscribeType } from '@/lib/api/contracts/user'
108
import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
119
import { InviteLayout } from '@/app/invite/components'
12-
13-
type UnsubscribeData = ContractJsonResponse<typeof unsubscribeGetContract>
10+
import { useUnsubscribe, useUnsubscribeMutation } from '@/hooks/queries/unsubscribe'
1411

1512
function UnsubscribeContent() {
1613
const searchParams = useSearchParams()
17-
const [loading, setLoading] = useState(true)
18-
const [data, setData] = useState<UnsubscribeData | null>(null)
19-
const [error, setError] = useState<string | null>(null)
20-
const [processing, setProcessing] = useState(false)
21-
const [unsubscribed, setUnsubscribed] = useState(false)
22-
2314
const email = searchParams.get('email')
2415
const token = searchParams.get('token')
2516

26-
useEffect(() => {
27-
if (!email || !token) {
28-
setError('Missing email or token in URL')
29-
setLoading(false)
30-
return
31-
}
32-
33-
requestJson(unsubscribeGetContract, { query: { email, token } })
34-
.then((response) => {
35-
setData(response)
36-
})
37-
.catch((err: unknown) => {
38-
const message = getErrorMessage(err, 'Failed to validate unsubscribe link')
39-
setError(message)
40-
})
41-
.finally(() => {
42-
setLoading(false)
43-
})
44-
}, [email, token])
45-
46-
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
17+
const hasParams = Boolean(email) && Boolean(token)
18+
const query = useUnsubscribe(email ?? undefined, token ?? undefined)
19+
const unsubscribe = useUnsubscribeMutation()
20+
21+
const data = query.data ?? null
22+
const loading = hasParams && query.isLoading
23+
const processing = unsubscribe.isPending
24+
const unsubscribed = unsubscribe.isSuccess
25+
const error = !hasParams
26+
? 'Missing email or token in URL'
27+
: query.isError
28+
? getErrorMessage(query.error, 'Failed to validate unsubscribe link')
29+
: unsubscribe.isError
30+
? getErrorMessage(unsubscribe.error, 'Failed to process unsubscribe request')
31+
: null
32+
33+
const handleUnsubscribe = (type: UnsubscribeType) => {
4734
if (!email || !token) return
48-
49-
setProcessing(true)
50-
51-
try {
52-
await requestJson(unsubscribePostContract, {
53-
body: { email, token, type },
54-
})
55-
56-
setUnsubscribed(true)
57-
if (data) {
58-
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
59-
if (validTypes.includes(type)) {
60-
if (type === 'all') {
61-
setData({
62-
...data,
63-
currentPreferences: {
64-
...data.currentPreferences,
65-
unsubscribeAll: true,
66-
},
67-
})
68-
} else {
69-
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
70-
| 'unsubscribeMarketing'
71-
| 'unsubscribeUpdates'
72-
| 'unsubscribeNotifications'
73-
setData({
74-
...data,
75-
currentPreferences: {
76-
...data.currentPreferences,
77-
[propertyKey]: true,
78-
},
79-
})
80-
}
81-
}
82-
}
83-
} catch (err: unknown) {
84-
const message = getErrorMessage(err, 'Failed to process unsubscribe request')
85-
setError(message)
86-
} finally {
87-
setProcessing(false)
88-
}
35+
unsubscribe.mutate({ email, token, type })
8936
}
9037

9138
if (loading) {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2+
import { requestJson } from '@/lib/api/client/request'
3+
import {
4+
type UnsubscribeActionResponse,
5+
type UnsubscribeData,
6+
type UnsubscribeType,
7+
unsubscribeGetContract,
8+
unsubscribePostContract,
9+
} from '@/lib/api/contracts/user'
10+
11+
export const unsubscribeKeys = {
12+
all: ['unsubscribe'] as const,
13+
details: () => [...unsubscribeKeys.all, 'detail'] as const,
14+
detail: (email?: string, token?: string) =>
15+
[...unsubscribeKeys.details(), email ?? '', token ?? ''] as const,
16+
}
17+
18+
async function fetchUnsubscribe(
19+
email: string,
20+
token: string,
21+
signal?: AbortSignal
22+
): Promise<UnsubscribeData> {
23+
return requestJson(unsubscribeGetContract, { query: { email, token }, signal })
24+
}
25+
26+
/**
27+
* Validates an unsubscribe link and loads the recipient's current email preferences.
28+
* Auto-runs on mount once both `email` and `token` are present.
29+
*/
30+
export function useUnsubscribe(email?: string, token?: string) {
31+
return useQuery({
32+
queryKey: unsubscribeKeys.detail(email, token),
33+
queryFn: ({ signal }) => fetchUnsubscribe(email as string, token as string, signal),
34+
enabled: Boolean(email) && Boolean(token),
35+
staleTime: 5 * 60 * 1000,
36+
retry: false,
37+
})
38+
}
39+
40+
interface UnsubscribeVariables {
41+
email: string
42+
token: string
43+
type: UnsubscribeType
44+
}
45+
46+
/**
47+
* Submits an unsubscribe action and reconciles the cached preferences so the
48+
* affected option immediately reflects the unsubscribed state.
49+
*/
50+
export function useUnsubscribeMutation() {
51+
const queryClient = useQueryClient()
52+
return useMutation<UnsubscribeActionResponse, Error, UnsubscribeVariables>({
53+
mutationFn: ({ email, token, type }) =>
54+
requestJson(unsubscribePostContract, { body: { email, token, type } }),
55+
onSuccess: (_data, { email, token, type }) => {
56+
const key = unsubscribeKeys.detail(email, token)
57+
queryClient.setQueryData<UnsubscribeData>(key, (previous) => {
58+
if (!previous) return previous
59+
const preferenceKey =
60+
type === 'all'
61+
? 'unsubscribeAll'
62+
: (`unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
63+
| 'unsubscribeMarketing'
64+
| 'unsubscribeUpdates'
65+
| 'unsubscribeNotifications')
66+
return {
67+
...previous,
68+
currentPreferences: {
69+
...previous.currentPreferences,
70+
[preferenceKey]: true,
71+
},
72+
}
73+
})
74+
},
75+
})
76+
}

apps/sim/lib/api/contracts/user.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z } from 'zod'
2-
import { defineRouteContract } from '@/lib/api/contracts/types'
2+
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
33
import { isSameOrigin } from '@/lib/core/utils/validation'
44

55
export const userProfileSchema = z.object({
@@ -259,6 +259,11 @@ export const unsubscribePostContract = defineRouteContract({
259259
},
260260
})
261261

262+
export type UnsubscribeData = ContractJsonResponse<typeof unsubscribeGetContract>
263+
export type UnsubscribeActionResponse = ContractJsonResponse<typeof unsubscribePostContract>
264+
export type UnsubscribeBody = z.input<typeof unsubscribeBodySchema>
265+
export type UnsubscribeType = NonNullable<UnsubscribeBody['type']>
266+
262267
export const usageLogsQuerySchema = z.object({
263268
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
264269
workspaceId: z.string().optional(),

0 commit comments

Comments
 (0)