From 80010a6bc211d3530af8f4c779c5e300c172adf8 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 3 Jan 2026 20:32:53 +0000 Subject: [PATCH 01/57] Proxy auth backend requests --- web/app/api/api.server.ts | 403 ++++++++++++++++++++++++++ web/app/api/index.ts | 2 + web/app/pages/Auth/Signin/Signin.tsx | 203 ++++--------- web/app/pages/Auth/Signup/Signup.tsx | 211 +++----------- web/app/pages/Dashboard/Dashboard.tsx | 54 ++-- web/app/providers/AuthProvider.tsx | 59 ++-- web/app/root.tsx | 42 ++- web/app/routes/dashboard.tsx | 49 +++- web/app/routes/login.tsx | 71 ++++- web/app/routes/logout.tsx | 25 ++ web/app/routes/signup.tsx | 78 ++++- web/app/utils/cookie.ts | 6 +- web/app/utils/session.server.ts | 142 +++++++++ 13 files changed, 985 insertions(+), 360 deletions(-) create mode 100644 web/app/api/api.server.ts create mode 100644 web/app/routes/logout.tsx create mode 100644 web/app/utils/session.server.ts diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts new file mode 100644 index 000000000..c0c1e5752 --- /dev/null +++ b/web/app/api/api.server.ts @@ -0,0 +1,403 @@ +import { redirect } from 'react-router' + +import { Auth } from '~/lib/models/Auth' +import { User } from '~/lib/models/User' +import { + getAccessToken, + getRefreshToken, + createAuthCookies, + clearAuthCookies, + createHeadersWithCookies, + AuthTokens, +} from '~/utils/session.server' + +// ============================================================================ +// MARK: API utils +// ============================================================================ + +function getApiUrl(): string { + const isStaging = process.env.STAGING === 'true' + const isSelfhosted = Boolean(process.env.__SELFHOSTED) + + let apiUrl: string | undefined + + if (isSelfhosted || !isStaging) { + apiUrl = process.env.API_URL + } else { + apiUrl = process.env.API_STAGING_URL + } + + if (!apiUrl) { + throw new Error('API_URL environment variable is not set') + } + + // Ensure trailing slash + return apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/` +} + +export function getClientIP(request: Request): string | null { + const headers = request.headers + + const cfIP = headers.get('cf-connecting-ip') + if (cfIP) return cfIP + + const realIP = headers.get('x-real-ip') + if (realIP) return realIP + + const forwardedFor = headers.get('x-forwarded-for') + if (forwardedFor) { + return forwardedFor.split(',')[0].trim() + } + + return null +} + +export interface ServerFetchOptions { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + body?: Record + headers?: Record + /** Skip authentication - for public endpoints */ + skipAuth?: boolean +} + +export interface ServerFetchResult { + data: T | null + error: string | string[] | null + status: number + /** New cookies to set (e.g., after token refresh) */ + cookies: string[] +} + +export async function serverFetch( + request: Request, + endpoint: string, + options: ServerFetchOptions = {}, +): Promise> { + const { method = 'GET', body, headers: customHeaders = {}, skipAuth = false } = options + + const apiUrl = getApiUrl() + const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint + const url = `${apiUrl}${cleanEndpoint}` + + const fetchHeaders: Record = { + 'Content-Type': 'application/json', + ...customHeaders, + } + + const clientIP = getClientIP(request) + if (clientIP) { + fetchHeaders['X-Client-IP-Address'] = clientIP + } + + if (!skipAuth) { + const accessToken = getAccessToken(request) + if (accessToken) { + fetchHeaders['Authorization'] = `Bearer ${accessToken}` + } + } + + try { + const response = await fetch(url, { + method, + headers: fetchHeaders, + body: body ? JSON.stringify(body) : undefined, + }) + + if ((response.status === 401 || response.status === 403) && !skipAuth) { + const refreshResult = await tryRefreshToken(request) + + if (refreshResult.success && refreshResult.tokens) { + fetchHeaders['Authorization'] = `Bearer ${refreshResult.tokens.accessToken}` + + const retryResponse = await fetch(url, { + method, + headers: fetchHeaders, + body: body ? JSON.stringify(body) : undefined, + }) + + if (retryResponse.ok) { + const data = (await retryResponse.json()) as T + return { + data, + error: null, + status: retryResponse.status, + cookies: refreshResult.cookies, + } + } + + const errorData = await parseErrorResponse(retryResponse) + return { + data: null, + error: errorData, + status: retryResponse.status, + cookies: refreshResult.cookies, + } + } + + return { + data: null, + error: 'Session expired', + status: 401, + cookies: clearAuthCookies(), + } + } + + if (!response.ok) { + const errorData = await parseErrorResponse(response) + return { + data: null, + error: errorData, + status: response.status, + cookies: [], + } + } + + const contentType = response.headers.get('content-type') + if (!contentType?.includes('application/json')) { + return { + data: null, + error: null, + status: response.status, + cookies: [], + } + } + + const data = (await response.json()) as T + return { + data, + error: null, + status: response.status, + cookies: [], + } + } catch (error) { + console.error('[serverFetch] Request failed:', error) + return { + data: null, + error: error instanceof Error ? error.message : 'Network error', + status: 500, + cookies: [], + } + } +} + +async function tryRefreshToken( + request: Request, +): Promise<{ success: boolean; tokens?: AuthTokens; cookies: string[] }> { + const refreshToken = getRefreshToken(request) + + if (!refreshToken) { + return { success: false, cookies: [] } + } + + const apiUrl = getApiUrl() + + try { + const response = await fetch(`${apiUrl}v1/auth/refresh-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${refreshToken}`, + }, + }) + + if (!response.ok) { + return { success: false, cookies: clearAuthCookies() } + } + + const data = (await response.json()) as { accessToken: string } + + const tokens: AuthTokens = { + accessToken: data.accessToken, + refreshToken: refreshToken, + } + + const cookies = createAuthCookies(tokens, true) + + return { success: true, tokens, cookies } + } catch { + return { success: false, cookies: clearAuthCookies() } + } +} + +async function parseErrorResponse(response: Response): Promise { + try { + const contentType = response.headers.get('content-type') + if (!contentType?.includes('application/json')) { + return response.statusText || 'Request failed' + } + + const data = await response.json() + + // Handle various error response formats + if (data.message) { + return data.message + } + + if (data.error) { + return data.error + } + + return 'Request failed' + } catch { + return response.statusText || 'Request failed' + } +} + +// ============================================================================ +// MARK: Auth utils +// ============================================================================ + +export interface AuthenticatedUser { + user: User + totalMonthlyEvents: number +} + +export async function getAuthenticatedUser( + request: Request, +): Promise<{ user: AuthenticatedUser; cookies: string[] } | null> { + const accessToken = getAccessToken(request) + + if (!accessToken) { + return null + } + + const result = await serverFetch(request, 'user/me') + + if (result.error || !result.data) { + return null + } + + return { user: result.data, cookies: result.cookies } +} + +/** + * Require authentication for a route. + * Redirects to login if not authenticated. + * + * @returns The authenticated user and any cookies that need to be set + */ +export async function requireAuth( + request: Request, + redirectTo = '/login', +): Promise<{ user: AuthenticatedUser; cookies: string[] }> { + const accessToken = getAccessToken(request) + + if (!accessToken) { + throw redirect(redirectTo) + } + + const result = await serverFetch(request, 'user/me') + + if (result.error || !result.data) { + // Clear invalid cookies and redirect + throw redirect(redirectTo, { + headers: createHeadersWithCookies(clearAuthCookies()), + }) + } + + return { + user: result.data, + cookies: result.cookies, + } +} + +export async function loginUser( + request: Request, + credentials: { email: string; password: string }, + remember: boolean, +): Promise<{ + success: boolean + data?: Auth + error?: string | string[] + cookies: string[] + requires2FA?: boolean +}> { + const result = await serverFetch(request, 'v1/auth/login', { + method: 'POST', + body: credentials, + skipAuth: true, + }) + + if (result.error || !result.data) { + return { + success: false, + error: result.error || 'Login failed', + cookies: [], + } + } + + const { accessToken, refreshToken, user } = result.data + + if (user.isTwoFactorAuthenticationEnabled) { + const cookies = createAuthCookies({ accessToken, refreshToken }, false) // Session cookies for 2FA + return { + success: true, + data: result.data, + cookies, + requires2FA: true, + } + } + + const cookies = createAuthCookies({ accessToken, refreshToken }, remember) + + return { + success: true, + data: result.data, + cookies, + } +} + +export async function registerUser( + request: Request, + data: { email: string; password: string; checkIfLeaked?: boolean }, + remember: boolean, +): Promise<{ + success: boolean + data?: Auth + error?: string | string[] + cookies: string[] +}> { + const result = await serverFetch(request, 'v1/auth/register', { + method: 'POST', + body: data, + skipAuth: true, + }) + + if (result.error || !result.data) { + return { + success: false, + error: result.error || 'Registration failed', + cookies: [], + } + } + + const { accessToken, refreshToken } = result.data + + const cookies = createAuthCookies({ accessToken, refreshToken }, remember) + + return { + success: true, + data: result.data, + cookies, + } +} + +export async function logoutUser(request: Request): Promise<{ cookies: string[] }> { + const refreshToken = getRefreshToken(request) + + if (refreshToken) { + await serverFetch(request, 'v1/auth/logout', { + method: 'POST', + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + skipAuth: true, + }).catch(() => { + // Ignore errors - we're logging out anyway + }) + } + + return { + cookies: clearAuthCookies(), + } +} diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 0321f2872..0c0351c5b 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -31,6 +31,8 @@ import { getRefreshToken } from '~/utils/refreshToken' const api = axios.create({ baseURL: API_URL, + // Enable sending cookies with cross-origin requests + withCredentials: true, }) // Function that will be called to refresh authorization diff --git a/web/app/pages/Auth/Signin/Signin.tsx b/web/app/pages/Auth/Signin/Signin.tsx index 2538ae15f..c339fcbea 100644 --- a/web/app/pages/Auth/Signin/Signin.tsx +++ b/web/app/pages/Auth/Signin/Signin.tsx @@ -1,21 +1,18 @@ -import _isEmpty from 'lodash/isEmpty' import _isString from 'lodash/isString' -import _keys from 'lodash/keys' -import _omit from 'lodash/omit' -import React, { useState, useEffect, memo } from 'react' +import React, { useState, memo } from 'react' import { useTranslation, Trans } from 'react-i18next' -import { Link, useNavigate, useSearchParams } from 'react-router' +import { Link, useNavigate, useSearchParams, Form, useActionData, useNavigation } from 'react-router' import { toast } from 'sonner' -import { generateSSOAuthURL, getJWTBySSOHash, login, submit2FA } from '~/api' +import { generateSSOAuthURL, getJWTBySSOHash, submit2FA } from '~/api' import GithubAuth from '~/components/GithubAuth' import GoogleAuth from '~/components/GoogleAuth' import OIDCAuth from '~/components/OIDCAuth' -import { withAuthentication, auth } from '~/hoc/protected' import { isSelfhosted, TRIAL_DAYS } from '~/lib/constants' import { SSOProvider } from '~/lib/models/Auth' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' +import type { LoginActionData } from '~/routes/login' import Button from '~/ui/Button' import Checkbox from '~/ui/Checkbox' import Input from '~/ui/Input' @@ -25,62 +22,46 @@ import { setAccessToken, removeAccessToken } from '~/utils/accessToken' import { cn, delay, openBrowserWindow } from '~/utils/generic' import { setRefreshToken, removeRefreshToken } from '~/utils/refreshToken' import routes from '~/utils/routes' -import { isValidEmail, isValidPassword, MIN_PASSWORD_CHARS } from '~/utils/validator' - -interface SigninForm { - email: string - password: string - dontRemember: boolean -} +import { MIN_PASSWORD_CHARS } from '~/utils/validator' const HASH_CHECK_FREQUENCY = 1000 const Signin = () => { const navigate = useNavigate() + const navigation = useNavigation() + const actionData = useActionData() const [searchParams] = useSearchParams() const { t } = useTranslation('common') const { theme } = useTheme() - const [form, setForm] = useState({ - email: '', - password: '', - dontRemember: false, - }) - const [validated, setValidated] = useState(false) - const [errors, setErrors] = useState<{ - email?: string - password?: string - }>({}) - const [beenSubmitted, setBeenSubmitted] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [isTwoFARequired, setIsTwoFARequired] = useState(searchParams.get('show_2fa_screen') === 'true') + + const [dontRemember, setDontRemember] = useState(false) + + const [isSsoLoading, setIsSsoLoading] = useState(false) + + const [isTwoFARequired, setIsTwoFARequired] = useState( + searchParams.get('show_2fa_screen') === 'true' || actionData?.requires2FA === true, + ) const [twoFACode, setTwoFACode] = useState('') const [twoFACodeError, setTwoFACodeError] = useState(null) + const [is2FALoading, setIs2FALoading] = useState(false) + const { setUser, setTotalMonthlyEvents, setIsAuthenticated } = useAuth() - const validate = () => { - const allErrors = {} as { - email?: string - password?: string - } + const isFormSubmitting = navigation.state === 'submitting' + const isLoading = isFormSubmitting || isSsoLoading || is2FALoading - if (!isValidEmail(form.email)) { - allErrors.email = t('auth.common.badEmailError') + React.useEffect(() => { + if (actionData?.requires2FA) { + setIsTwoFARequired(true) } + }, [actionData?.requires2FA]) - if (!isValidPassword(form.password)) { - allErrors.password = t('auth.common.xCharsError', { amount: MIN_PASSWORD_CHARS }) + React.useEffect(() => { + if (actionData?.error && !actionData?.fieldErrors) { + const errorMessage = Array.isArray(actionData.error) ? actionData.error[0] : actionData.error + toast.error(errorMessage) } - - const valid = _isEmpty(_keys(allErrors)) - - setErrors(allErrors) - setValidated(valid) - } - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- Form validation on input change - validate() - }, [form]) // eslint-disable-line + }, [actionData?.error, actionData?.fieldErrors]) const handle2FAInput = (event: React.ChangeEvent) => { const { @@ -95,10 +76,12 @@ const Signin = () => { if (!authWindow) { toast.error(t('apiNotifications.socialisationAuthGenericError')) - setIsLoading(false) + setIsSsoLoading(false) return } + setIsSsoLoading(true) + try { const { uuid, @@ -134,7 +117,7 @@ const Signin = () => { if (!safeAuthUrl) { toast.error(t('apiNotifications.socialisationAuthGenericError')) - setIsLoading(false) + setIsSsoLoading(false) authWindow.close() return } @@ -156,7 +139,7 @@ const Signin = () => { setRefreshToken(refreshToken) setUser(user) setIsTwoFARequired(true) - setIsLoading(false) + setIsSsoLoading(false) return } @@ -179,67 +162,26 @@ const Signin = () => { } if (authWindow.closed) { - setIsLoading(false) + setIsSsoLoading(false) return } } } catch (reason) { toast.error(typeof reason === 'string' ? reason : t('apiNotifications.socialisationGenericError')) - setIsLoading(false) + setIsSsoLoading(false) return } } - const onSubmit = async (data: SigninForm) => { - if (isLoading) { - return - } - - setIsLoading(true) - - try { - const { dontRemember } = data - - const { user, accessToken, refreshToken, totalMonthlyEvents } = await login(_omit(data, ['dontRemember'])) - - if (user.isTwoFactorAuthenticationEnabled) { - setAccessToken(accessToken, true) - setRefreshToken(refreshToken, true) - setUser(user) - setIsTwoFARequired(true) - setIsLoading(false) - return - } - - setUser(user) - setIsAuthenticated(true) - setTotalMonthlyEvents(totalMonthlyEvents) - setAccessToken(accessToken, dontRemember) - setRefreshToken(refreshToken) - - setIsLoading(false) - - // Redirect to onboarding if user hasn't completed it - if (!user.hasCompletedOnboarding) { - navigate(routes.onboarding) - } else { - navigate(routes.dashboard) - } - } catch (reason) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - setIsLoading(false) - } - } - const _submit2FA = async (e: React.FormEvent) => { e.preventDefault() e.stopPropagation() - if (isLoading) { + if (is2FALoading) { return } - setIsLoading(true) + setIs2FALoading(true) try { const { accessToken, refreshToken, user } = await submit2FA(twoFACode) @@ -249,6 +191,12 @@ const Signin = () => { setRefreshToken(refreshToken) setUser(user) setIsAuthenticated(true) + + if (!user.hasCompletedOnboarding) { + navigate(routes.onboarding) + } else { + navigate(routes.dashboard) + } } catch (reason) { if (_isString(reason)) { toast.error(reason) @@ -258,24 +206,7 @@ const Signin = () => { } setTwoFACode('') - setIsLoading(false) - } - - const handleInput = ({ target }: { target: HTMLInputElement }) => { - setForm((oldForm) => ({ - ...oldForm, - [target.name]: target.value, - })) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - e.stopPropagation() - setBeenSubmitted(true) - - if (validated) { - onSubmit(form) - } + setIs2FALoading(false) } if (isTwoFARequired) { @@ -299,7 +230,7 @@ const Signin = () => { value={twoFACode} placeholder={t('auth.signin.6digitCode')} onChange={handle2FAInput} - disabled={isLoading} + disabled={is2FALoading} error={twoFACodeError} />
@@ -319,7 +250,7 @@ const Signin = () => { /> ) : null} -
@@ -362,10 +293,8 @@ const Signin = () => { return (
- {/* Left side - Form */}
- {/* Header */}
{t('auth.signin.title')} @@ -375,7 +304,6 @@ const Signin = () => {
- {/* SSO Buttons */}
{isSelfhosted ? ( onSsoLogin('openid-connect')} disabled={isLoading} className='w-full' /> @@ -387,7 +315,6 @@ const Signin = () => { )}
- {/* Divider */}
- {/* Form */} -
+ { } hint={t('auth.common.hint', { amount: MIN_PASSWORD_CHARS })} - value={form.password} - onChange={handleInput} - error={beenSubmitted ? errors.password : ''} + error={actionData?.fieldErrors?.password} + disabled={isLoading} /> + {/* Hidden input for form submission since Headless UI Checkbox doesn't submit natively */} + +
- setForm((prev) => ({ - ...prev, - dontRemember: checked, - })) - } - name='dontRemember' + checked={dontRemember} + onChange={setDontRemember} + disabled={isLoading} label={ {t('auth.common.noRemember')} @@ -448,10 +370,10 @@ const Signin = () => { />
- -
+ {/* Sign up link */} {!isSelfhosted ? ( @@ -477,14 +399,11 @@ const Signin = () => {
- {/* Right side - Visual showcase */}
- {/* Decorative gradient orbs */}
- {/* Welcome message */}
{t('auth.signin.dashboardAwaits')} @@ -494,7 +413,6 @@ const Signin = () => {
- {/* Dashboard preview */}
{
- {/* Testimonial */}
@@ -535,4 +452,4 @@ const Signin = () => { ) } -export default memo(withAuthentication(Signin, auth.notAuthenticated)) +export default memo(Signin) diff --git a/web/app/pages/Auth/Signup/Signup.tsx b/web/app/pages/Auth/Signup/Signup.tsx index ccc05f653..2d72702cf 100644 --- a/web/app/pages/Auth/Signup/Signup.tsx +++ b/web/app/pages/Auth/Signup/Signup.tsx @@ -1,13 +1,10 @@ -import _isEmpty from 'lodash/isEmpty' -import _keys from 'lodash/keys' -import _size from 'lodash/size' import { BarChart3Icon, MousePointerClickIcon, ShieldCheckIcon, SparklesIcon } from 'lucide-react' -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, memo } from 'react' import { useTranslation, Trans } from 'react-i18next' -import { Link, useNavigate } from 'react-router' +import { Link, useNavigate, Form, useActionData, useNavigation } from 'react-router' import { toast } from 'sonner' -import { generateSSOAuthURL, getJWTBySSOHash, signup } from '~/api' +import { generateSSOAuthURL, getJWTBySSOHash } from '~/api' import GithubAuth from '~/components/GithubAuth' import GoogleAuth from '~/components/GoogleAuth' import OIDCAuth from '~/components/OIDCAuth' @@ -15,6 +12,7 @@ import { HAVE_I_BEEN_PWNED_URL, isSelfhosted, TRIAL_DAYS } from '~/lib/constants import { SSOProvider } from '~/lib/models/Auth' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' +import type { SignupActionData } from '~/routes/signup' import Button from '~/ui/Button' import Checkbox from '~/ui/Checkbox' import Input from '~/ui/Input' @@ -24,16 +22,7 @@ import { setAccessToken } from '~/utils/accessToken' import { cn, delay, openBrowserWindow } from '~/utils/generic' import { setRefreshToken } from '~/utils/refreshToken' import routes from '~/utils/routes' -import { isValidEmail, isValidPassword, MIN_PASSWORD_CHARS, MAX_PASSWORD_CHARS } from '~/utils/validator' - -interface SignupForm { - email: string - password: string - repeat: string - tos: boolean - dontRemember: boolean - checkIfLeaked: boolean -} +import { MIN_PASSWORD_CHARS } from '~/utils/validator' const HASH_CHECK_FREQUENCY = 1000 @@ -44,109 +33,38 @@ const featureKeys = ['realTimeAnalytics', 'eventTracking', 'privacyFirst', 'intu const Signup = () => { const { t } = useTranslation('common') const { theme } = useTheme() - const [form, setForm] = useState({ - email: '', - password: '', - repeat: '', - tos: false, - dontRemember: false, - checkIfLeaked: true, - }) - const [validated, setValidated] = useState(false) - const [errors, setErrors] = useState<{ - email?: string - password?: string - repeat?: string - tos?: string - }>({}) - const [beenSubmitted, setBeenSubmitted] = useState(false) - const [isLoading, setIsLoading] = useState(false) const navigate = useNavigate() - const { isAuthenticated, setUser, setTotalMonthlyEvents, setIsAuthenticated } = useAuth() - - const validate = () => { - const allErrors = {} as { - email?: string - password?: string - repeat?: string - tos?: string - } - - if (!isValidEmail(form.email)) { - allErrors.email = t('auth.common.badEmailError') - } - - if (!isValidPassword(form.password)) { - allErrors.password = t('auth.common.xCharsError', { amount: MIN_PASSWORD_CHARS }) - } - - if (form.password !== form.repeat || form.repeat === '') { - allErrors.repeat = t('auth.common.noMatchError') - } - - if (_size(form.password) > 50) { - allErrors.password = t('auth.common.passwordTooLong', { amount: MAX_PASSWORD_CHARS }) - } + const navigation = useNavigation() + const actionData = useActionData() - if (!form.tos && !isSelfhosted) { - allErrors.tos = t('auth.common.tosError') - } + const [tos, setTos] = useState(false) + const [checkIfLeaked, setCheckIfLeaked] = useState(true) - const valid = _isEmpty(_keys(allErrors)) + const [isSsoLoading, setIsSsoLoading] = useState(false) - setErrors(allErrors) - setValidated(valid) - } + const { setUser, setTotalMonthlyEvents, setIsAuthenticated } = useAuth() - useEffect(() => { - validate() - }, [form]) // eslint-disable-line + const isFormSubmitting = navigation.state === 'submitting' + const isLoading = isFormSubmitting || isSsoLoading useEffect(() => { - if (isAuthenticated && !beenSubmitted) { - navigate(routes.dashboard) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAuthenticated, beenSubmitted]) - - const onSubmit = async (data: SignupForm) => { - if (isLoading) { - return - } - - setIsLoading(true) - - try { - const { dontRemember } = data - - const { user, accessToken, refreshToken } = await signup({ - email: data.email, - password: data.password, - checkIfLeaked: data.checkIfLeaked, - }) - - setUser(user) - setIsAuthenticated(true) - setAccessToken(accessToken, dontRemember) - setRefreshToken(refreshToken) - setIsLoading(false) - - navigate(routes.onboarding) - } catch (reason) { - toast.error((reason as { message?: string })?.message || t('apiNotifications.somethingWentWrong')) - setIsLoading(false) + if (actionData?.error && !actionData?.fieldErrors) { + const errorMessage = Array.isArray(actionData.error) ? actionData.error[0] : actionData.error + toast.error(errorMessage) } - } + }, [actionData?.error, actionData?.fieldErrors]) const onSsoLogin = async (provider: SSOProvider) => { const authWindow = openBrowserWindow('') if (!authWindow) { toast.error(t('apiNotifications.socialisationAuthGenericError')) - setIsLoading(false) + setIsSsoLoading(false) return } + setIsSsoLoading(true) + try { const { uuid, auth_url: authUrl, expires_in: expiresIn } = await generateSSOAuthURL(provider) @@ -178,7 +96,7 @@ const Signup = () => { if (!safeAuthUrl) { toast.error(t('apiNotifications.socialisationAuthGenericError')) - setIsLoading(false) + setIsSsoLoading(false) authWindow.close() return } @@ -200,7 +118,7 @@ const Signup = () => { setRefreshToken(refreshToken) setUser(user) navigate(`${routes.signin}?show_2fa_screen=true`) - setIsLoading(false) + setIsSsoLoading(false) return } @@ -223,42 +141,21 @@ const Signup = () => { } if (authWindow.closed) { - setIsLoading(false) + setIsSsoLoading(false) return } } } catch (reason) { toast.error((reason as { message?: string })?.message || t('apiNotifications.socialisationGenericError')) - setIsLoading(false) + setIsSsoLoading(false) return } } - const handleInput = (event: React.ChangeEvent) => { - const { target } = event - - setForm((oldForm) => ({ - ...oldForm, - [target.name]: target.value, - })) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - e.stopPropagation() - setBeenSubmitted(true) - - if (validated) { - onSubmit(form) - } - } - return (
- {/* Left side - Form */}
- {/* Header */}
{isSelfhosted @@ -274,7 +171,6 @@ const Signup = () => { )}
- {/* SSO Buttons */}
{isSelfhosted ? ( onSsoLogin('openid-connect')} disabled={isLoading} className='w-full' /> @@ -286,7 +182,6 @@ const Signup = () => { )}
- {/* Divider */}
- {/* Form */} -
+ + {/* Hidden fields for checkbox values since Headless UI Checkbox doesn't submit natively */} + + + {isSelfhosted ? null : ( - setForm((prev) => ({ - ...prev, - tos: checked, - })) - } - name='tos' + checked={tos} + onChange={setTos} + disabled={isLoading} label={ { classes={{ hint: '!text-red-600 dark:!text-red-500', }} - hint={beenSubmitted ? errors.tos : ''} + hint={actionData?.fieldErrors?.tos} /> )}
- setForm((prev) => ({ - ...prev, - checkIfLeaked: checked, - })) - } - name='checkIfLeaked' + checked={checkIfLeaked} + onChange={setCheckIfLeaked} + disabled={isLoading} label={{t('auth.common.checkLeakedPassword')}} /> { />
- - + - {/* Sign in link */} {
- {/* Right side - Visual showcase */}
- {/* Decorative gradient orbs */}
- {/* Features list */}
{t('auth.signup.insightsInMinutes')} @@ -463,7 +344,6 @@ const Signup = () => {
- {/* Dashboard preview */}
{
- {/* Testimonial */}
@@ -504,4 +383,4 @@ const Signup = () => { ) } -export default Signup +export default memo(Signup) diff --git a/web/app/pages/Dashboard/Dashboard.tsx b/web/app/pages/Dashboard/Dashboard.tsx index 70a3edc2f..2d8914543 100644 --- a/web/app/pages/Dashboard/Dashboard.tsx +++ b/web/app/pages/Dashboard/Dashboard.tsx @@ -4,7 +4,7 @@ import _keys from 'lodash/keys' import _map from 'lodash/map' import _size from 'lodash/size' import { StretchHorizontalIcon, LayoutGridIcon, SearchIcon, XIcon, FolderPlusIcon } from 'lucide-react' -import React, { useState, useEffect, useRef, useMemo } from 'react' +import React, { useState, useEffect, useRef, useMemo, memo } from 'react' import { useTranslation } from 'react-i18next' import { useLoaderData, useNavigate, useSearchParams } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' @@ -13,12 +13,12 @@ import { toast } from 'sonner' import { getProjects, getLiveVisitors, getOverallStats, createProject } from '~/api' import DashboardLockedBanner from '~/components/DashboardLockedBanner' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' -import { withAuthentication, auth } from '~/hoc/protected' import useBreakpoint from '~/hooks/useBreakpoint' import useDebounce from '~/hooks/useDebounce' import { isSelfhosted, LIVE_VISITORS_UPDATE_INTERVAL, tbPeriodPairs } from '~/lib/constants' import { Overall, Project } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' +import type { DashboardLoaderData } from '~/routes/dashboard' import Input from '~/ui/Input' import Modal from '~/ui/Modal' import Pagination from '~/ui/Pagination' @@ -45,20 +45,23 @@ const MAX_PROJECT_NAME_LENGTH = 50 const DEFAULT_PROJECT_NAME = 'Untitled Project' const Dashboard = () => { - const { viewMode: defaultViewMode } = useLoaderData() - const { user, isLoading: authLoading } = useAuth() + const loaderData = useLoaderData() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() + const { user } = useAuth() + const { t } = useTranslation('common') const [isSearchActive, setIsSearchActive] = useState(false) const searchInputRef = useRef(null) const [showActivateEmailModal, setShowActivateEmailModal] = useState(false) - const [viewMode, setViewMode] = useState(defaultViewMode) + const [viewMode, setViewMode] = useState(loaderData.viewMode) const isAboveLgBreakpoint = useBreakpoint('lg') - const [projects, setProjects] = useState([]) - const [paginationTotal, setPaginationTotal] = useState(0) + // Initialize projects from loader data + const [projects, setProjects] = useState(loaderData.projects?.results || []) + const [paginationTotal, setPaginationTotal] = useState(loaderData.projects?.total || 0) + const page = useMemo(() => { const pageParam = searchParams.get('page') @@ -81,7 +84,9 @@ const Dashboard = () => { ? parseInt(pageSizeParam, 10) : PAGE_SIZE_OPTIONS[0] }) - const [isLoading, setIsLoading] = useState(null) + + // Start with false since we have initial data from loader + const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const [liveStats, setLiveStats] = useState>({}) const [overallStats, setOverallStats] = useState({}) @@ -123,6 +128,9 @@ const Dashboard = () => { const [search, setSearch] = useState('') const debouncedSearch = useDebounce(search, 500) + // Track if user has interacted (changed page, search, etc.) - need to refetch + const [hasUserInteracted, setHasUserInteracted] = useState(false) + // Update URL only when values are explicitly changed const updateURL = (params: Record) => { const newSearchParams = new URLSearchParams(searchParams) @@ -139,23 +147,27 @@ const Dashboard = () => { } const handlePageChange = (newPage: number) => { + setHasUserInteracted(true) setPage(newPage) updateURL({ page: newPage.toString() }) } const handlePageSizeChange = (size: number) => { + setHasUserInteracted(true) setPageSize(size) setPage(1) updateURL({ pageSize: size.toString(), page: '1' }) } const handlePeriodChange = (period: string) => { + setHasUserInteracted(true) setActivePeriod(period) setPage(1) updateURL({ period, page: '1' }) } const handleSortChange = (sort: string) => { + setHasUserInteracted(true) setSortBy(sort) setPage(1) updateURL({ sort, page: '1' }) @@ -274,15 +286,23 @@ const Dashboard = () => { } }, [user, navigate]) + // Only fetch on user interaction (search, pagination, sort, period changes) useEffect(() => { - if (authLoading) { + if (!hasUserInteracted) { return } loadProjects(pageSize, (page - 1) * pageSize, debouncedSearch, activePeriod, sortBy) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, pageSize, debouncedSearch, activePeriod, authLoading, sortBy]) + }, [page, pageSize, debouncedSearch, activePeriod, sortBy, hasUserInteracted]) + + // Track search changes as user interaction + useEffect(() => { + if (debouncedSearch !== '') { + setHasUserInteracted(true) + } + }, [debouncedSearch]) // Set up interval for live visitors useEffect(() => { @@ -332,7 +352,7 @@ const Dashboard = () => { return () => clearInterval(interval) }, [projects, activePeriod, t]) // Reset interval when projects change - if (error && isLoading === false) { + if (error && !isLoading) { return ( { - +
{viewMode === DASHBOARD_VIEW.GRID ? (
) : null} - {isLoading || isLoading === null ? ( + {isLoading ? (
@@ -605,4 +621,4 @@ const Dashboard = () => { ) } -export default withAuthentication(Dashboard, auth.authenticated) +export default memo(Dashboard) diff --git a/web/app/providers/AuthProvider.tsx b/web/app/providers/AuthProvider.tsx index eba6ac927..9db95b7c0 100644 --- a/web/app/providers/AuthProvider.tsx +++ b/web/app/providers/AuthProvider.tsx @@ -12,7 +12,7 @@ interface AuthContextType { isLoading: boolean user: User | null totalMonthlyEvents: number - logout: (invalidateAllSessions?: boolean) => void + logout: (invalidateAllSessions?: boolean) => Promise setUser: (user: User) => void mergeUser: (newUser: Partial) => void setTotalMonthlyEvents: (totalMonthlyEvents: number) => void @@ -25,25 +25,40 @@ const AuthContext = createContext(undefined) interface AuthProviderProps { children: React.ReactNode initialIsAuthenticated: boolean + initialUser?: User | null + initialTotalMonthlyEvents?: number } -export const AuthProvider = ({ children, initialIsAuthenticated }: AuthProviderProps) => { - const [isAuthenticated, setIsAuthenticated] = useState(initialIsAuthenticated) - const [isLoading, setIsLoading] = useState(true) - const [user, setUser] = useState(null) +export const AuthProvider = ({ + children, + initialIsAuthenticated, + initialUser, + initialTotalMonthlyEvents, +}: AuthProviderProps) => { + // If we have initial user from loader, we're definitely authenticated + const [isAuthenticated, setIsAuthenticated] = useState(initialIsAuthenticated || !!initialUser) + + // If we have initial user data from loader, no need to load - start with isLoading = false + const [isLoading, setIsLoading] = useState(!initialUser && initialIsAuthenticated) + + const [user, setUser] = useState(initialUser || null) // TODO: @deprecated - const [totalMonthlyEvents, setTotalMonthlyEvents] = useState(0) + const [totalMonthlyEvents, setTotalMonthlyEvents] = useState(initialTotalMonthlyEvents || 0) - const logout = useCallback((invalidateAllSessions?: boolean) => { + const logout = useCallback(async (invalidateAllSessions?: boolean) => { setIsAuthenticated(false) setUser(null) - logoutCookies(invalidateAllSessions) + await logoutCookies(invalidateAllSessions) }, []) const mergeUser = useCallback((newUser: Partial) => { setUser((prev) => { if (!prev) { + // If no previous user, set the new user as full user (if it has required fields) + if ('id' in newUser && 'email' in newUser) { + return newUser as User + } return null } @@ -73,43 +88,49 @@ export const AuthProvider = ({ children, initialIsAuthenticated }: AuthProviderP ) useEffect(() => { + // If we already have user data from loader, no need to fetch again + if (initialUser) { + setIsLoading(false) + return + } + if (!initialIsAuthenticated) { setIsLoading(false) return } + // No initial user but have cookies - need to fetch user data const abortController = new AbortController() - loadUser(abortController.signal) return () => abortController.abort() - }, [initialIsAuthenticated, loadUser]) + }, [initialIsAuthenticated, initialUser, loadUser]) useEffect(() => { - const abortController = new AbortController() + // Skip if we already have user or are loading + if (user || isLoading) { + return + } - if (initialIsAuthenticated) { - loadUser(abortController.signal) - return () => abortController.abort() + // Skip if initially authenticated (already handled above) + if (initialIsAuthenticated || initialUser) { + return } // Client-side rehydration: if tokens exist but SSR did not receive cookies const hasClientToken = Boolean(getAccessToken() || getRefreshToken()) if (hasClientToken) { + const abortController = new AbortController() setIsLoading(true) loadUser(abortController.signal) return () => abortController.abort() } - - setIsLoading(false) - return () => abortController.abort() - }, [initialIsAuthenticated, loadUser]) + }, [initialIsAuthenticated, initialUser, user, isLoading, loadUser]) return ( 0) { + return data(loaderData, { + headers: createHeadersWithCookies(cookies), + }) + } + + return loaderData } export const handle = { i18n: 'common' } const Body = () => { - const { isAuthed } = useLoaderData() + const { isAuthed, user, totalMonthlyEvents } = useLoaderData() const { theme } = useTheme() return ( - + diff --git a/web/app/routes/dashboard.tsx b/web/app/routes/dashboard.tsx index f099a7a41..f8bd62abd 100644 --- a/web/app/routes/dashboard.tsx +++ b/web/app/routes/dashboard.tsx @@ -1,17 +1,60 @@ -import type { LoaderFunction } from 'react-router' +import type { LoaderFunctionArgs } from 'react-router' +import { data, redirect } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' +import { Project } from '~/lib/models/Project' import Dashboard from '~/pages/Dashboard' +import { hasAuthTokens, createHeadersWithCookies } from '~/utils/session.server' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export const loader: LoaderFunction = async ({ request }) => { +export interface DashboardLoaderData { + viewMode: 'grid' | 'list' + projects: { + results: Project[] + total: number + page_total: number + } | null +} + +export async function loader({ request }: LoaderFunctionArgs) { + if (!hasAuthTokens(request)) { + return redirect('/login') + } + + const url = new URL(request.url) + const page = parseInt(url.searchParams.get('page') || '1', 10) + const pageSize = parseInt(url.searchParams.get('pageSize') || '12', 10) + const search = url.searchParams.get('search') || '' + const period = url.searchParams.get('period') || '7d' + const sort = url.searchParams.get('sort') || 'alpha_asc' + + const skip = (page - 1) * pageSize + const cookieHeader = request.headers.get('Cookie') const viewMode = (cookieHeader?.match(/(?<=dashboard_view=)[^;]*/)?.[0] as 'grid' | 'list') || 'grid' - return { viewMode } + const projectsResult = await serverFetch<{ + results: Project[] + total: number + page_total: number + }>(request, `/project?take=${pageSize}&skip=${skip}&search=${search}&period=${period}&sort=${sort}`) + + const loaderData: DashboardLoaderData = { + viewMode, + projects: projectsResult.data, + } + + if (projectsResult.cookies.length > 0) { + return data(loaderData, { + headers: createHeadersWithCookies(projectsResult.cookies), + }) + } + + return loaderData } export default function DashboardPage() { diff --git a/web/app/routes/login.tsx b/web/app/routes/login.tsx index b8d6dfb72..f50ecd273 100644 --- a/web/app/routes/login.tsx +++ b/web/app/routes/login.tsx @@ -1,12 +1,81 @@ -import type { HeadersFunction } from 'react-router' +import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from 'react-router' +import { redirect, data } from 'react-router' +import { getAuthenticatedUser, loginUser } from '~/api/api.server' import Signin from '~/pages/Auth/Signin' +import { createHeadersWithCookies } from '~/utils/session.server' export const headers: HeadersFunction = ({ parentHeaders }) => { parentHeaders.set('X-Frame-Options', 'DENY') return parentHeaders } +export async function loader({ request }: LoaderFunctionArgs) { + const authResult = await getAuthenticatedUser(request) + + if (authResult) { + if (!authResult.user.user.hasCompletedOnboarding) { + return redirect('/onboarding') + } + return redirect('/dashboard') + } + + return null +} + +export interface LoginActionData { + error?: string | string[] + requires2FA?: boolean + fieldErrors?: { + email?: string + password?: string + } +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData() + + const email = formData.get('email')?.toString() || '' + const password = formData.get('password')?.toString() || '' + const dontRemember = formData.get('dontRemember') === 'true' + + const fieldErrors: LoginActionData['fieldErrors'] = {} + + if (!email || !email.includes('@')) { + fieldErrors.email = 'Please enter a valid email address' + } + + if (!password || password.length < 8) { + fieldErrors.password = 'Password must be at least 8 characters' + } + + if (fieldErrors.email || fieldErrors.password) { + return data({ fieldErrors }, { status: 400 }) + } + + const result = await loginUser(request, { email, password }, !dontRemember) + + if (!result.success) { + return data({ error: result.error }, { status: 400 }) + } + + if (result.requires2FA) { + return data( + { requires2FA: true }, + { + status: 200, + headers: createHeadersWithCookies(result.cookies), + }, + ) + } + + const redirectTo = result.data?.user.hasCompletedOnboarding ? '/dashboard' : '/onboarding' + + return redirect(redirectTo, { + headers: createHeadersWithCookies(result.cookies), + }) +} + export default function SigninRoute() { return } diff --git a/web/app/routes/logout.tsx b/web/app/routes/logout.tsx new file mode 100644 index 000000000..5f56cdddc --- /dev/null +++ b/web/app/routes/logout.tsx @@ -0,0 +1,25 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router' +import { redirect } from 'react-router' + +import { logoutUser } from '~/api/api.server' +import { createHeadersWithCookies } from '~/utils/session.server' + +export async function loader({ request }: LoaderFunctionArgs) { + const { cookies } = await logoutUser(request) + + return redirect('/login', { + headers: createHeadersWithCookies(cookies), + }) +} + +export async function action({ request }: ActionFunctionArgs) { + const { cookies } = await logoutUser(request) + + return redirect('/login', { + headers: createHeadersWithCookies(cookies), + }) +} + +export default function LogoutRoute() { + return null +} diff --git a/web/app/routes/signup.tsx b/web/app/routes/signup.tsx index 6f284c1d7..5af28f8fa 100644 --- a/web/app/routes/signup.tsx +++ b/web/app/routes/signup.tsx @@ -1,8 +1,11 @@ -import type { HeadersFunction } from 'react-router' +import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from 'react-router' +import { redirect, data } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { getAuthenticatedUser, registerUser } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' -import Singup from '~/pages/Auth/Signup' +import Signup from '~/pages/Auth/Signup' +import { createHeadersWithCookies } from '~/utils/session.server' export const headers: HeadersFunction = ({ parentHeaders }) => { parentHeaders.set('X-Frame-Options', 'DENY') @@ -14,6 +17,75 @@ export const sitemap: SitemapFunction = () => ({ exclude: isSelfhosted, }) +export async function loader({ request }: LoaderFunctionArgs) { + const authResult = await getAuthenticatedUser(request) + + if (authResult) { + if (!authResult.user.user.hasCompletedOnboarding) { + return redirect('/onboarding') + } + return redirect('/dashboard') + } + + return null +} + +export interface SignupActionData { + error?: string | string[] + fieldErrors?: { + email?: string + password?: string + repeat?: string + tos?: string + } +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData() + + const email = formData.get('email')?.toString() || '' + const password = formData.get('password')?.toString() || '' + const repeat = formData.get('repeat')?.toString() || '' + const tos = formData.get('tos') === 'true' + const checkIfLeaked = formData.get('checkIfLeaked') === 'true' + + const fieldErrors: SignupActionData['fieldErrors'] = {} + + if (!email || !email.includes('@')) { + fieldErrors.email = 'Please enter a valid email address' + } + + if (!password || password.length < 8) { + fieldErrors.password = 'Password must be at least 8 characters' + } + + if (password.length > 50) { + fieldErrors.password = 'Password must be at most 50 characters' + } + + if (password !== repeat) { + fieldErrors.repeat = 'Passwords do not match' + } + + if (!tos && !isSelfhosted) { + fieldErrors.tos = 'You must accept the Terms of Service' + } + + if (fieldErrors.email || fieldErrors.password || fieldErrors.repeat || fieldErrors.tos) { + return data({ fieldErrors }, { status: 400 }) + } + + const result = await registerUser(request, { email, password, checkIfLeaked }, true) + + if (!result.success) { + return data({ error: result.error }, { status: 400 }) + } + + return redirect('/onboarding', { + headers: createHeadersWithCookies(result.cookies), + }) +} + export default function SignupPage() { - return + return } diff --git a/web/app/utils/cookie.ts b/web/app/utils/cookie.ts index 6856f2f36..2405ee285 100644 --- a/web/app/utils/cookie.ts +++ b/web/app/utils/cookie.ts @@ -10,7 +10,11 @@ export const getCookie = (key: string) => { const match = document.cookie.match(new RegExp(`(^| )${key}=([^;]+)`)) if (match) { - return match[2] + try { + return decodeURIComponent(match[2]) + } catch { + return match[2] + } } return null diff --git a/web/app/utils/session.server.ts b/web/app/utils/session.server.ts new file mode 100644 index 000000000..be7b14649 --- /dev/null +++ b/web/app/utils/session.server.ts @@ -0,0 +1,142 @@ +const ACCESS_TOKEN_COOKIE = 'access_token' +const REFRESH_TOKEN_COOKIE = 'refresh_token' + +const PERSISTENT_COOKIE_MAX_AGE = 8467200 // 14 weeks in seconds + +export interface AuthTokens { + accessToken: string + refreshToken: string +} + +function getCookieDomain(): string | undefined { + const domain = process.env.COOKIE_DOMAIN + + // In development or selfhosted without explicit domain, don't set domain + // TODO: Eventually we should support this variable for selfhosted Swetrix as well + if (!domain || process.env.NODE_ENV === 'development' || process.env.__SELFHOSTED) { + return undefined + } + + return domain +} + +function isSecureCookie(): boolean { + // TODO: Eventually we should support this variable for selfhosted Swetrix as well + return process.env.NODE_ENV === 'production' && !process.env.__SELFHOSTED +} + +function parseCookies(request: Request): Record { + const cookieHeader = request.headers.get('Cookie') + if (!cookieHeader) return {} + + const cookies: Record = {} + for (const cookie of cookieHeader.split(';')) { + const [name, ...valueParts] = cookie.trim().split('=') + if (name) { + cookies[name] = valueParts.join('=') + } + } + return cookies +} + +export function getAccessToken(request: Request): string | null { + const cookies = parseCookies(request) + return cookies[ACCESS_TOKEN_COOKIE] || null +} + +export function getRefreshToken(request: Request): string | null { + const cookies = parseCookies(request) + return cookies[REFRESH_TOKEN_COOKIE] || null +} + +export function hasAuthTokens(request: Request): boolean { + return !!getAccessToken(request) || !!getRefreshToken(request) +} + +function buildCookieHeader( + name: string, + value: string, + options: { + maxAge?: number // undefined = session cookie (expires on browser close) + path?: string + sameSite?: 'Strict' | 'Lax' | 'None' + secure?: boolean + httpOnly?: boolean + domain?: string + } = {}, +): string { + const { + maxAge, + path = '/', + sameSite = 'Strict', + secure = isSecureCookie(), + // TODO: At the moment we intentionally don't use HttpOnly so the client-side axios interceptor + // can read tokens and add Authorization headers. + // Enable this when all API calls are migrated to use loaders/actions + httpOnly = false, + domain = getCookieDomain(), + } = options + + let cookie = `${name}=${encodeURIComponent(value)}; Path=${path}; SameSite=${sameSite}` + + // Only add Max-Age for persistent cookies + // Session cookies (remember=false) should NOT have Max-Age + if (maxAge !== undefined) { + cookie += `; Max-Age=${maxAge}` + } + + if (secure) { + cookie += '; Secure' + } + + if (httpOnly) { + cookie += '; HttpOnly' + } + + if (domain) { + cookie += `; Domain=${domain}` + } + + return cookie +} + +export function createAuthCookies(tokens: AuthTokens, remember: boolean): string[] { + const maxAge = remember ? PERSISTENT_COOKIE_MAX_AGE : undefined + + return [ + buildCookieHeader(ACCESS_TOKEN_COOKIE, tokens.accessToken, { maxAge }), + buildCookieHeader(REFRESH_TOKEN_COOKIE, tokens.refreshToken, { maxAge }), + ] +} + +/** + * @returns Array of Set-Cookie header values that expire the cookies + */ +export function clearAuthCookies(): string[] { + const domain = getCookieDomain() + + // Clear with and without domain to ensure both are removed + const cookies = [ + `${ACCESS_TOKEN_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`, + `${REFRESH_TOKEN_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`, + ] + + if (domain) { + cookies.push(`${ACCESS_TOKEN_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; Domain=${domain}`) + cookies.push(`${REFRESH_TOKEN_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; Domain=${domain}`) + } + + return cookies +} + +export function appendCookiesToHeaders(headers: Headers, cookies: string[]): Headers { + for (const cookie of cookies) { + headers.append('Set-Cookie', cookie) + } + return headers +} + +export function createHeadersWithCookies(cookies: string[]): Headers { + const headers = new Headers() + return appendCookiesToHeaders(headers, cookies) +} From 8ffb3ed7136187307ed10b60d4323349e2151612 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 3 Jan 2026 20:39:57 +0000 Subject: [PATCH 02/57] clean-up --- web/app/api/api.server.ts | 42 ++++----------------------------- web/app/api/index.ts | 22 +---------------- web/app/routes/dashboard.tsx | 8 +++---- web/app/utils/server.ts | 11 --------- web/app/utils/session.server.ts | 14 ++++++++++- 5 files changed, 21 insertions(+), 76 deletions(-) diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index c0c1e5752..9fc873993 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -1,5 +1,3 @@ -import { redirect } from 'react-router' - import { Auth } from '~/lib/models/Auth' import { User } from '~/lib/models/User' import { @@ -7,7 +5,6 @@ import { getRefreshToken, createAuthCookies, clearAuthCookies, - createHeadersWithCookies, AuthTokens, } from '~/utils/session.server' @@ -35,7 +32,7 @@ function getApiUrl(): string { return apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/` } -export function getClientIP(request: Request): string | null { +function getClientIP(request: Request): string | null { const headers = request.headers const cfIP = headers.get('cf-connecting-ip') @@ -52,7 +49,7 @@ export function getClientIP(request: Request): string | null { return null } -export interface ServerFetchOptions { +interface ServerFetchOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' body?: Record headers?: Record @@ -60,7 +57,7 @@ export interface ServerFetchOptions { skipAuth?: boolean } -export interface ServerFetchResult { +interface ServerFetchResult { data: T | null error: string | string[] | null status: number @@ -247,7 +244,7 @@ async function parseErrorResponse(response: Response): Promise { - const accessToken = getAccessToken(request) - - if (!accessToken) { - throw redirect(redirectTo) - } - - const result = await serverFetch(request, 'user/me') - - if (result.error || !result.data) { - // Clear invalid cookies and redirect - throw redirect(redirectTo, { - headers: createHeadersWithCookies(clearAuthCookies()), - }) - } - - return { - user: result.data, - cookies: result.cookies, - } -} - export async function loginUser( request: Request, credentials: { email: string; password: string }, diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 0c0351c5b..96d989fea 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -6,7 +6,7 @@ import _map from 'lodash/map' import { DEFAULT_ALERTS_TAKE, API_URL } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' -import { Auth, SSOProvider } from '~/lib/models/Auth' +import { SSOProvider } from '~/lib/models/Auth' import { Metainfo } from '~/lib/models/Metainfo' import { Role } from '~/lib/models/Organisation' import { @@ -104,26 +104,6 @@ export const logoutAllApi = () => throw new Error(error.respon) }) -export const login = (credentials: { email: string; password: string }) => - api - .post('v1/auth/login', credentials) - .then((response): Auth => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const signup = (data: { email: string; password: string; checkIfLeaked?: boolean }) => - api - .post('v1/auth/register', data) - .then((response): Auth => response.data) - .catch((error) => { - const errorsArray = error.response.data.message - if (_isArray(errorsArray)) { - throw errorsArray - } - throw new Error(errorsArray) - }) - export const deleteUser = (deletionFeedback?: string) => api .delete('/user', { diff --git a/web/app/routes/dashboard.tsx b/web/app/routes/dashboard.tsx index f8bd62abd..8ea26ed2f 100644 --- a/web/app/routes/dashboard.tsx +++ b/web/app/routes/dashboard.tsx @@ -1,11 +1,11 @@ import type { LoaderFunctionArgs } from 'react-router' -import { data, redirect } from 'react-router' +import { data } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' import { serverFetch } from '~/api/api.server' import { Project } from '~/lib/models/Project' import Dashboard from '~/pages/Dashboard' -import { hasAuthTokens, createHeadersWithCookies } from '~/utils/session.server' +import { redirectIfNotAuthenticated, createHeadersWithCookies } from '~/utils/session.server' export const sitemap: SitemapFunction = () => ({ exclude: true, @@ -21,9 +21,7 @@ export interface DashboardLoaderData { } export async function loader({ request }: LoaderFunctionArgs) { - if (!hasAuthTokens(request)) { - return redirect('/login') - } + redirectIfNotAuthenticated(request) const url = new URL(request.url) const page = parseInt(url.searchParams.get('page') || '1', 10) diff --git a/web/app/utils/server.ts b/web/app/utils/server.ts index 189c968be..45acd79dc 100644 --- a/web/app/utils/server.ts +++ b/web/app/utils/server.ts @@ -36,17 +36,6 @@ export function detectTheme(request: Request): ThemeType { return 'light' } -function getAccessToken(request: Request): string | null { - const cookie = request.headers.get('Cookie') - const accessToken = cookie?.match(/(?<=access_token=)[^;]*/)?.[0] - - return accessToken || null -} - -export function isAuthenticated(request: Request): boolean { - return !!getAccessToken(request) -} - export function isWWW(url: URL): boolean { return _startsWith(url.hostname, 'www.') } diff --git a/web/app/utils/session.server.ts b/web/app/utils/session.server.ts index be7b14649..dd0da8e84 100644 --- a/web/app/utils/session.server.ts +++ b/web/app/utils/session.server.ts @@ -1,3 +1,5 @@ +import { redirect } from 'react-router' + const ACCESS_TOKEN_COOKIE = 'access_token' const REFRESH_TOKEN_COOKIE = 'refresh_token' @@ -53,6 +55,16 @@ export function hasAuthTokens(request: Request): boolean { return !!getAccessToken(request) || !!getRefreshToken(request) } +export function redirectIfNotAuthenticated(request: Request, redirectTo = '/login'): Response | null { + if (hasAuthTokens(request)) { + return null + } + + throw redirect(redirectTo, { + headers: createHeadersWithCookies(clearAuthCookies()), + }) +} + function buildCookieHeader( name: string, value: string, @@ -129,7 +141,7 @@ export function clearAuthCookies(): string[] { return cookies } -export function appendCookiesToHeaders(headers: Headers, cookies: string[]): Headers { +function appendCookiesToHeaders(headers: Headers, cookies: string[]): Headers { for (const cookie of cookies) { headers.append('Set-Cookie', cookie) } From b5a709d17f98d970eacd2e98db8a295423bd6a70 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 3 Jan 2026 20:49:58 +0000 Subject: [PATCH 03/57] Migrate logout to API proxying --- web/app/api/api.server.ts | 31 ++++++++++++++++++++++-------- web/app/api/index.ts | 27 ++++---------------------- web/app/providers/AuthProvider.tsx | 12 ++++++++---- web/app/routes/logout.tsx | 11 +++++++++-- web/app/utils/auth.ts | 23 +++++----------------- 5 files changed, 49 insertions(+), 55 deletions(-) diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 9fc873993..8ef5cfbed 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -348,19 +348,34 @@ export async function registerUser( } } -export async function logoutUser(request: Request): Promise<{ cookies: string[] }> { - const refreshToken = getRefreshToken(request) +export async function logoutUser( + request: Request, + options: { logoutAll?: boolean } = {}, +): Promise<{ cookies: string[] }> { + const { logoutAll = false } = options - if (refreshToken) { - await serverFetch(request, 'v1/auth/logout', { + if (logoutAll) { + // Logout all sessions - uses access token + await serverFetch(request, 'v1/auth/logout-all', { method: 'POST', - headers: { - Authorization: `Bearer ${refreshToken}`, - }, - skipAuth: true, }).catch(() => { // Ignore errors - we're logging out anyway }) + } else { + // Logout single session - uses refresh token + const refreshToken = getRefreshToken(request) + + if (refreshToken) { + await serverFetch(request, 'v1/auth/logout', { + method: 'POST', + headers: { + Authorization: `Bearer ${refreshToken}`, + }, + skipAuth: true, + }).catch(() => { + // Ignore errors - we're logging out anyway + }) + } } return { diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 96d989fea..b400bc142 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -26,7 +26,7 @@ import { Subscriber } from '~/lib/models/Subscriber' import { User } from '~/lib/models/User' import { Filter, ProjectViewCustomEvent } from '~/pages/Project/View/interfaces/traffic' import { getAccessToken, setAccessToken } from '~/utils/accessToken' -import { logout } from '~/utils/auth' +import { clearLocalStorageOnLogout } from '~/utils/auth' import { getRefreshToken } from '~/utils/refreshToken' const api = axios.create({ @@ -51,8 +51,9 @@ const refreshAuthLogic = (failedRequest: { response: AxiosResponse }) => return Promise.resolve() }) .catch((error) => { - logout() - // If user session is invalid, this rejection will be thrown to authMe in AuthProvider and will reset user there too + clearLocalStorageOnLogout() + // Redirect to server-side logout which handles cookie cleanup + window.location.href = '/logout' return Promise.reject(error) }) @@ -84,26 +85,6 @@ export const authMe = (config?: AxiosRequestConfig) => } => response.data, ) -export const logoutApi = (refreshToken: string | null) => - axios - .post(`${API_URL}v1/auth/logout`, null, { - headers: { - Authorization: `Bearer ${refreshToken}`, - }, - }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const logoutAllApi = () => - api - .post(`${API_URL}v1/auth/logout-all`) - .then((response) => response.data) - .catch((error) => { - throw new Error(error.respon) - }) - export const deleteUser = (deletionFeedback?: string) => api .delete('/user', { diff --git a/web/app/providers/AuthProvider.tsx b/web/app/providers/AuthProvider.tsx index 9db95b7c0..b1dcbec56 100644 --- a/web/app/providers/AuthProvider.tsx +++ b/web/app/providers/AuthProvider.tsx @@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea import { authMe } from '~/api' import { User } from '~/lib/models/User' import { getAccessToken } from '~/utils/accessToken' -import { logout as logoutCookies } from '~/utils/auth' +import { clearLocalStorageOnLogout } from '~/utils/auth' import { getRefreshToken } from '~/utils/refreshToken' interface AuthContextType { @@ -12,7 +12,7 @@ interface AuthContextType { isLoading: boolean user: User | null totalMonthlyEvents: number - logout: (invalidateAllSessions?: boolean) => Promise + logout: (invalidateAllSessions?: boolean) => void setUser: (user: User) => void mergeUser: (newUser: Partial) => void setTotalMonthlyEvents: (totalMonthlyEvents: number) => void @@ -46,10 +46,14 @@ export const AuthProvider = ({ // TODO: @deprecated const [totalMonthlyEvents, setTotalMonthlyEvents] = useState(initialTotalMonthlyEvents || 0) - const logout = useCallback(async (invalidateAllSessions?: boolean) => { + const logout = useCallback((invalidateAllSessions?: boolean) => { setIsAuthenticated(false) setUser(null) - await logoutCookies(invalidateAllSessions) + clearLocalStorageOnLogout() + + // Navigate to server-side logout route which handles cookie cleanup + const logoutUrl = invalidateAllSessions ? '/logout?logoutAll=true' : '/logout' + window.location.href = logoutUrl }, []) const mergeUser = useCallback((newUser: Partial) => { diff --git a/web/app/routes/logout.tsx b/web/app/routes/logout.tsx index 5f56cdddc..d3e3a96d5 100644 --- a/web/app/routes/logout.tsx +++ b/web/app/routes/logout.tsx @@ -4,8 +4,14 @@ import { redirect } from 'react-router' import { logoutUser } from '~/api/api.server' import { createHeadersWithCookies } from '~/utils/session.server' +function getLogoutAllParam(request: Request): boolean { + const url = new URL(request.url) + return url.searchParams.get('logoutAll') === 'true' +} + export async function loader({ request }: LoaderFunctionArgs) { - const { cookies } = await logoutUser(request) + const logoutAll = getLogoutAllParam(request) + const { cookies } = await logoutUser(request, { logoutAll }) return redirect('/login', { headers: createHeadersWithCookies(cookies), @@ -13,7 +19,8 @@ export async function loader({ request }: LoaderFunctionArgs) { } export async function action({ request }: ActionFunctionArgs) { - const { cookies } = await logoutUser(request) + const logoutAll = getLogoutAllParam(request) + const { cookies } = await logoutUser(request, { logoutAll }) return redirect('/login', { headers: createHeadersWithCookies(cookies), diff --git a/web/app/utils/auth.ts b/web/app/utils/auth.ts index 78d39b840..529d2725b 100644 --- a/web/app/utils/auth.ts +++ b/web/app/utils/auth.ts @@ -1,4 +1,3 @@ -import { logoutAllApi, logoutApi } from '~/api' import { isSelfhosted, LOW_EVENTS_WARNING, @@ -7,10 +6,8 @@ import { SHOW_BANNER_AT_PERC, } from '~/lib/constants' -import { removeAccessToken } from './accessToken' import { getCookie } from './cookie' import { removeItem } from './localstorage' -import { getRefreshToken, removeRefreshToken } from './refreshToken' export const shouldShowLowEventsBanner = (totalMonthlyEvents: number, maxEventsCount: number) => { if (isSelfhosted) { @@ -28,21 +25,11 @@ export const shouldShowLowEventsBanner = (totalMonthlyEvents: number, maxEventsC return eventsUsedPercentage >= SHOW_BANNER_AT_PERC } -export const logout = async (invalidateAllSessions?: boolean) => { - const refreshToken = getRefreshToken() - - try { - if (invalidateAllSessions) { - await logoutAllApi() - } else { - await logoutApi(refreshToken) - } - } catch (reason) { - console.error('Failed to invalidate refresh token', reason) - } - - removeAccessToken() +/** + * Clears local storage items related to user session. + * Called before navigating to /logout route. + */ +export const clearLocalStorageOnLogout = () => { removeItem(LS_VIEW_PREFS_SETTING) removeItem(LS_CAPTCHA_VIEW_PREFS_SETTING) - removeRefreshToken() } From e34507bc197efd9e772656b1d0994347b6669182 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 4 Jan 2026 02:38:09 +0000 Subject: [PATCH 04/57] Migrate all forms to RR actions --- .../cloud/src/project/project.controller.ts | 2 +- .../src/project/project.controller.ts | 2 +- web/app/api/index.ts | 272 ------------------ .../CreateNewPassword/CreateNewPassword.tsx | 114 ++------ .../Auth/ForgotPassword/ForgotPassword.tsx | 96 ++----- web/app/pages/Auth/Signin/Signin.tsx | 68 ++--- web/app/pages/Dashboard/Dashboard.tsx | 58 ++-- web/app/pages/Onboarding/Onboarding.tsx | 110 +++---- web/app/pages/Organisations/Organisations.tsx | 56 ++-- .../pages/Organisations/Settings/index.tsx | 79 ++--- .../Project/Settings/ProjectSettings.tsx | 272 +++++++++--------- .../Project/tabs/Goals/GoalSettingsModal.tsx | 59 ++-- .../pages/Project/tabs/Goals/GoalsView.tsx | 23 +- web/app/pages/UserSettings/UserSettings.tsx | 257 +++++++---------- .../pages/UserSettings/components/TwoFA.tsx | 134 ++++----- web/app/routes/dashboard.tsx | 60 +++- web/app/routes/login.tsx | 40 ++- web/app/routes/onboarding.tsx | 115 +++++++- web/app/routes/organisations.$id.tsx | 146 +++++++++- web/app/routes/organisations._index.tsx | 67 ++++- web/app/routes/password-reset.$id.tsx | 51 +++- web/app/routes/projects.$id.tsx | 217 +++++++++++++- web/app/routes/projects.settings.$id.tsx | 211 +++++++++++++- web/app/routes/recovery.tsx | 37 ++- web/app/routes/user-settings.tsx | 251 ++++++++++++++++ web/public/locales/en.json | 1 + 26 files changed, 1746 insertions(+), 1052 deletions(-) diff --git a/backend/apps/cloud/src/project/project.controller.ts b/backend/apps/cloud/src/project/project.controller.ts index c6144d915..cfc676216 100644 --- a/backend/apps/cloud/src/project/project.controller.ts +++ b/backend/apps/cloud/src/project/project.controller.ts @@ -830,7 +830,7 @@ export class ProjectController { await deleteProjectRedis(pid) - return secret + return { captchaSecretKey: secret } } @Delete('/partially/:pid') diff --git a/backend/apps/community/src/project/project.controller.ts b/backend/apps/community/src/project/project.controller.ts index 03f83b353..4899d9a6c 100644 --- a/backend/apps/community/src/project/project.controller.ts +++ b/backend/apps/community/src/project/project.controller.ts @@ -621,7 +621,7 @@ export class ProjectController { await deleteProjectRedis(pid) - return secret + return { captchaSecretKey: secret } } @ApiBearerAuth() diff --git a/web/app/api/index.ts b/web/app/api/index.ts index b400bc142..e61abfcc9 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -1,6 +1,5 @@ import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' import createAuthRefreshInterceptor from 'axios-auth-refresh' -import _isArray from 'lodash/isArray' import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' @@ -85,70 +84,6 @@ export const authMe = (config?: AxiosRequestConfig) => } => response.data, ) -export const deleteUser = (deletionFeedback?: string) => - api - .delete('/user', { - data: { - feedback: deletionFeedback, - }, - }) - .then((response) => response.data) - .catch((error) => { - throw new Error(JSON.stringify(error.response.data)) - }) - -export const changeUserDetails = (data: User) => - api - .put('/user', data) - .then((response): User => response.data) - .catch((error) => { - const errorsArray = error.response.data.message - if (_isArray(errorsArray)) { - throw errorsArray - } - throw new Error(errorsArray) - }) - -export const setShowLiveVisitorsInTitle = (show: boolean) => - api - .put('/user/live-visitors', { show }) - .then((response): Partial => response.data) - .catch((error) => { - const errorsArray = error.response.data.message - if (_isArray(errorsArray)) { - throw errorsArray - } - throw new Error(errorsArray) - }) - -export const forgotPassword = (email: { email: string }) => - api - .post('v1/auth/reset-password', email) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const confirmEmail = () => - api - .post('/user/confirm_email') - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const createNewPassword = (id: string, password: string) => - api - .post(`v1/auth/reset-password/confirm/${id}`, { newPassword: password }) - .then((response) => response.data) - .catch((error) => { - const errorsArray = error.response.data.message - if (_isArray(errorsArray)) { - throw errorsArray - } - throw new Error(errorsArray) - }) - export const verifyEmail = ({ id }: { id: string }) => api .get(`v1/auth/verify-email/${id}`) @@ -213,38 +148,6 @@ export const getOrganisation = (id: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const deleteOrganisation = (id: string) => - api - .delete(`/organisation/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const assignProjectToOrganisation = (projectId: string, organisationId?: string) => - api - .patch(`/project/${projectId}/organisation`, { organisationId }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const createOrganisation = (name: string) => - api - .post('/organisation', { name }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const updateOrganisation = (organisationId: string, data: { name: string }) => - api - .patch(`/organisation/${organisationId}`, data) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const addProjectToOrganisation = (organisationId: string, projectId: string) => api .post(`/project/organisation/${organisationId}`, { projectId }) @@ -313,30 +216,6 @@ export const getProject = (pid: string, password?: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const createProject = (data: { name: string; organisationId?: string }) => - api - .post('/project', data) - .then((response): Project => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const updateProject = (id: string, data: Partial) => - api - .put(`/project/${id}`, data) - .then((response): Project => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const deleteProject = (id: string) => - api - .delete(`/project/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const pinProject = (id: string) => api .post(`/project/${id}/pin`) @@ -353,14 +232,6 @@ export const unpinProject = (id: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const resetProject = (id: string) => - api - .delete(`/project/reset/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProjectData = ( pid: string, tb = 'hour', @@ -1004,60 +875,6 @@ export const changeShareRole = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const generate2FA = () => - api - .post('2fa/generate') - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const enable2FA = (twoFactorAuthenticationCode: string) => - api - .post('2fa/enable', { twoFactorAuthenticationCode }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const disable2FA = (twoFactorAuthenticationCode: string) => - api - .post('2fa/disable', { twoFactorAuthenticationCode }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const submit2FA = (twoFactorAuthenticationCode: string) => - api - .post('2fa/authenticate', { twoFactorAuthenticationCode }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const generateApiKey = () => - api - .post('user/api-key') - .then( - ( - response, - ): { - apiKey: string - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const deleteApiKey = () => - api - .delete('user/api-key') - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export interface GetLiveVisitorsInfo { psid: string dv: string @@ -1207,30 +1024,6 @@ export type CreateGoal = { metadataFilters?: { key: string; value: string }[] } -export const createGoal = (data: CreateGoal) => - api - .post('goal', data) - .then((response): Goal => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const updateGoal = (id: string, data: Partial) => - api - .put(`goal/${id}`, data) - .then((response): Goal => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const deleteGoal = (id: string) => - api - .delete(`goal/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getGoalStats = (goalId: string, period: string, from: string = '', to: string = '', timezone?: string) => api .get(`/goal/${goalId}/stats`, { @@ -1608,14 +1401,6 @@ export const getExperimentResults = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const reGenerateCaptchaSecretKey = (pid: string) => - api - .post(`project/secret-gen/${pid}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const addSubscriber = ( id: string, data: { @@ -1762,17 +1547,6 @@ export const getProjectDataCustomEvents = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const transferProject = (uuid: string, email: string) => - api - .post('project/transfer', { - projectId: uuid, - email, - }) - .then((response): unknown => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const rejectTransferProject = (uuid: string) => api .delete(`project/transfer?token=${uuid}`) @@ -1875,20 +1649,6 @@ export const processSSOToken = (token: string, hash: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const deletePartially = ( - id: string, - data: { - from: string - to: string - }, -) => - api - .delete(`project/partially/${id}?from=${data.from}&to=${data.to}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getUserFlow = ( pid: string, tb = 'hour', @@ -1979,22 +1739,6 @@ export const getVersionFilters = (pid: string, type: 'traffic' | 'errors', colum throw error }) -export const resetFilters = (pid: string, type: string, filters: string[]) => - api - .delete(`project/reset-filters/${pid}?type=${type}&filters=${JSON.stringify(filters)}`) - .then((response) => response.data) - .catch((error) => { - throw error - }) - -export const receiveLoginNotification = (receiveLoginNotifications: boolean) => - api - .post('user/recieve-login-notifications', { receiveLoginNotifications }) - .then((response) => response.data) - .catch((error) => { - throw error - }) - export const previewSubscriptionUpdate = (planId: number) => api .post('user/preview-plan', { planId }) @@ -2067,22 +1811,6 @@ export const updateErrorStatus = (pid: string, status: 'resolved' | 'active', ei throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const updateOnboardingStep = (step: string) => - api - .post('/user/onboarding/step', { step }) - .then((response): User => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const completeOnboarding = () => - api - .post('/user/onboarding/complete') - .then((response): User => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - // Google Search Console integration export const generateGSCAuthURL = (pid: string) => api diff --git a/web/app/pages/Auth/CreateNewPassword/CreateNewPassword.tsx b/web/app/pages/Auth/CreateNewPassword/CreateNewPassword.tsx index e96f8267b..6d6a72430 100644 --- a/web/app/pages/Auth/CreateNewPassword/CreateNewPassword.tsx +++ b/web/app/pages/Auth/CreateNewPassword/CreateNewPassword.tsx @@ -1,102 +1,26 @@ -import _isEmpty from 'lodash/isEmpty' -import _keys from 'lodash/keys' -import _size from 'lodash/size' -import React, { useState, useEffect } from 'react' +import React, { useEffect } from 'react' import { useTranslation, Trans } from 'react-i18next' -import { Link, useNavigate, useParams } from 'react-router' +import { Link, Form, useActionData, useNavigation } from 'react-router' import { toast } from 'sonner' -import { createNewPassword } from '~/api' -import { withAuthentication, auth } from '~/hoc/protected' +import type { CreateNewPasswordActionData } from '~/routes/password-reset.$id' import Button from '~/ui/Button' import Input from '~/ui/Input' import routes from '~/utils/routes' -import { isValidPassword, MIN_PASSWORD_CHARS, MAX_PASSWORD_CHARS } from '~/utils/validator' - -interface FormSubmitData { - password: string - repeat: string -} +import { MIN_PASSWORD_CHARS } from '~/utils/validator' const CreateNewPassword = () => { const { t } = useTranslation('common') - const navigate = useNavigate() - const { id } = useParams() - const [form, setForm] = useState({ - password: '', - repeat: '', - }) - const [validated, setValidated] = useState(false) - const [errors, setErrors] = useState<{ - password?: string - repeat?: string - }>({}) - const [beenSubmitted, setBeenSubmitted] = useState(false) - const [isLoading, setIsLoading] = useState(false) - - const validate = () => { - const allErrors = {} as { - password?: string - repeat?: string - } - - if (!isValidPassword(form.password)) { - allErrors.password = t('auth.common.xCharsError', { amount: MIN_PASSWORD_CHARS }) - } - - if (form.password !== form.repeat || form.repeat === '') { - allErrors.repeat = t('auth.common.noMatchError') - } - - if (_size(form.password) > 50) { - allErrors.password = t('auth.common.passwordTooLong', { amount: MAX_PASSWORD_CHARS }) - } - - const valid = _isEmpty(_keys(allErrors)) + const navigation = useNavigation() + const actionData = useActionData() - setErrors(allErrors) - setValidated(valid) - } + const isSubmitting = navigation.state === 'submitting' useEffect(() => { - validate() - }, [form]) // eslint-disable-line - - const onSubmit = async (data: FormSubmitData) => { - if (!isLoading) { - setIsLoading(true) - try { - const { password } = data - await createNewPassword(id as string, password) - - toast.success(t('auth.recovery.updated')) - navigate(routes.signin) - } catch (reason: any) { - toast.error(reason.toString()) - } finally { - setIsLoading(false) - } - } - } - - const handleInput = ({ target }: { target: HTMLInputElement }) => { - const value = target.type === 'checkbox' ? target.checked : target.value - - setForm((oldForm) => ({ - ...oldForm, - [target.name]: value, - })) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - e.stopPropagation() - setBeenSubmitted(true) - - if (validated) { - onSubmit(form) + if (actionData?.error) { + toast.error(actionData.error) } - } + }, [actionData?.error]) return (
@@ -108,28 +32,26 @@ const CreateNewPassword = () => {
-
+ - -
+

{ ) } -export default withAuthentication(CreateNewPassword, auth.notAuthenticated) +export default CreateNewPassword diff --git a/web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx b/web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx index 8613c2eaa..a7deeed60 100644 --- a/web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx +++ b/web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx @@ -1,87 +1,33 @@ -import _isEmpty from 'lodash/isEmpty' -import _keys from 'lodash/keys' -import React, { useState, useEffect } from 'react' +import React, { useEffect } from 'react' import { useTranslation, Trans } from 'react-i18next' -import { Link, useNavigate } from 'react-router' +import { Link, Form, useActionData, useNavigation, useSearchParams } from 'react-router' import { toast } from 'sonner' -import { forgotPassword } from '~/api' -import { withAuthentication, auth } from '~/hoc/protected' import { isSelfhosted, TRIAL_DAYS } from '~/lib/constants' +import type { ForgotPasswordActionData } from '~/routes/recovery' import Button from '~/ui/Button' import Input from '~/ui/Input' import routes from '~/utils/routes' -import { isValidEmail } from '~/utils/validator' const ForgotPassword = () => { const { t } = useTranslation('common') - const navigate = useNavigate() - const [form, setForm] = useState<{ - email: string - }>({ - email: '', - }) - const [validated, setValidated] = useState(false) - const [errors, setErrors] = useState<{ - email?: string - }>({}) - const [beenSubmitted, setBeenSubmitted] = useState(false) - const [isLoading, setIsLoading] = useState(false) + const navigation = useNavigation() + const actionData = useActionData() + const [searchParams] = useSearchParams() - const validate = () => { - const allErrors = {} as { - email?: string - } - - if (!isValidEmail(form.email)) { - allErrors.email = t('auth.common.badEmailError') - } - - const valid = _isEmpty(_keys(allErrors)) - - setErrors(allErrors) - setValidated(valid) - } + const isSubmitting = navigation.state === 'submitting' useEffect(() => { - validate() - }, [form]) // eslint-disable-line - - const onSubmit = async (data: { email: string }) => { - if (!isLoading) { - setIsLoading(true) - - try { - await forgotPassword(data) - - toast.success(t('auth.forgot.sent')) - navigate(routes.main) - } catch (reason: any) { - toast.error(reason.toString()) - } finally { - setIsLoading(false) - } + if (searchParams.get('password_reset_sent') === 'true') { + toast.success(t('auth.forgot.sent')) } - } - - const handleInput = ({ target }: { target: HTMLInputElement }) => { - const value = target.type === 'checkbox' ? target.checked : target.value + }, [searchParams, t]) - setForm((oldForm) => ({ - ...oldForm, - [target.name]: value, - })) - } - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - e.stopPropagation() - setBeenSubmitted(true) - - if (validated) { - onSubmit(form) + useEffect(() => { + if (actionData?.error) { + toast.error(actionData.error) } - } + }, [actionData?.error]) return (

@@ -93,19 +39,19 @@ const ForgotPassword = () => {
-
+ - -
+
{isSelfhosted ? null : (

@@ -133,4 +79,4 @@ const ForgotPassword = () => { ) } -export default withAuthentication(ForgotPassword, auth.notAuthenticated) +export default ForgotPassword diff --git a/web/app/pages/Auth/Signin/Signin.tsx b/web/app/pages/Auth/Signin/Signin.tsx index c339fcbea..79f5d73fe 100644 --- a/web/app/pages/Auth/Signin/Signin.tsx +++ b/web/app/pages/Auth/Signin/Signin.tsx @@ -1,10 +1,9 @@ -import _isString from 'lodash/isString' -import React, { useState, memo } from 'react' +import React, { useState, memo, useEffect } from 'react' import { useTranslation, Trans } from 'react-i18next' -import { Link, useNavigate, useSearchParams, Form, useActionData, useNavigation } from 'react-router' +import { Link, useNavigate, useSearchParams, Form, useActionData, useNavigation, useFetcher } from 'react-router' import { toast } from 'sonner' -import { generateSSOAuthURL, getJWTBySSOHash, submit2FA } from '~/api' +import { generateSSOAuthURL, getJWTBySSOHash } from '~/api' import GithubAuth from '~/components/GithubAuth' import GoogleAuth from '~/components/GoogleAuth' import OIDCAuth from '~/components/OIDCAuth' @@ -18,9 +17,9 @@ import Checkbox from '~/ui/Checkbox' import Input from '~/ui/Input' import { Text } from '~/ui/Text' import Tooltip from '~/ui/Tooltip' -import { setAccessToken, removeAccessToken } from '~/utils/accessToken' +import { setAccessToken } from '~/utils/accessToken' import { cn, delay, openBrowserWindow } from '~/utils/generic' -import { setRefreshToken, removeRefreshToken } from '~/utils/refreshToken' +import { setRefreshToken } from '~/utils/refreshToken' import routes from '~/utils/routes' import { MIN_PASSWORD_CHARS } from '~/utils/validator' @@ -30,6 +29,7 @@ const Signin = () => { const navigate = useNavigate() const navigation = useNavigation() const actionData = useActionData() + const twoFAFetcher = useFetcher() const [searchParams] = useSearchParams() const { t } = useTranslation('common') const { theme } = useTheme() @@ -38,25 +38,24 @@ const Signin = () => { const [isSsoLoading, setIsSsoLoading] = useState(false) - const [isTwoFARequired, setIsTwoFARequired] = useState( - searchParams.get('show_2fa_screen') === 'true' || actionData?.requires2FA === true, - ) const [twoFACode, setTwoFACode] = useState('') - const [twoFACodeError, setTwoFACodeError] = useState(null) - const [is2FALoading, setIs2FALoading] = useState(false) + // State for SSO-triggered 2FA (client-side flow) + const [sso2FARequired, setSso2FARequired] = useState(false) const { setUser, setTotalMonthlyEvents, setIsAuthenticated } = useAuth() const isFormSubmitting = navigation.state === 'submitting' + const is2FALoading = twoFAFetcher.state === 'submitting' const isLoading = isFormSubmitting || isSsoLoading || is2FALoading - React.useEffect(() => { - if (actionData?.requires2FA) { - setIsTwoFARequired(true) - } - }, [actionData?.requires2FA]) + // 2FA is required from URL params, action data, or SSO flow + const isTwoFARequired = + searchParams.get('show_2fa_screen') === 'true' || actionData?.requires2FA === true || sso2FARequired + + // Derive 2FA error from fetcher data + const twoFACodeError = twoFAFetcher.data?.fieldErrors?.twoFACode || null - React.useEffect(() => { + useEffect(() => { if (actionData?.error && !actionData?.fieldErrors) { const errorMessage = Array.isArray(actionData.error) ? actionData.error[0] : actionData.error toast.error(errorMessage) @@ -68,7 +67,6 @@ const Signin = () => { target: { value }, } = event setTwoFACode(value) - setTwoFACodeError(null) } const onSsoLogin = async (provider: SSOProvider) => { @@ -138,7 +136,7 @@ const Signin = () => { setAccessToken(accessToken, true) setRefreshToken(refreshToken) setUser(user) - setIsTwoFARequired(true) + setSso2FARequired(true) setIsSsoLoading(false) return } @@ -173,7 +171,7 @@ const Signin = () => { } } - const _submit2FA = async (e: React.FormEvent) => { + const _submit2FA = (e: React.FormEvent) => { e.preventDefault() e.stopPropagation() @@ -181,32 +179,12 @@ const Signin = () => { return } - setIs2FALoading(true) - - try { - const { accessToken, refreshToken, user } = await submit2FA(twoFACode) - removeAccessToken() - removeRefreshToken() - setAccessToken(accessToken) - setRefreshToken(refreshToken) - setUser(user) - setIsAuthenticated(true) - - if (!user.hasCompletedOnboarding) { - navigate(routes.onboarding) - } else { - navigate(routes.dashboard) - } - } catch (reason) { - if (_isString(reason)) { - toast.error(reason) - } - console.error(`[ERROR] Failed to authenticate with 2FA: ${reason}`) - setTwoFACodeError(t('profileSettings.invalid2fa')) - } + const formData = new FormData() + formData.set('intent', 'submit-2fa') + formData.set('twoFACode', twoFACode) + formData.set('dontRemember', dontRemember.toString()) - setTwoFACode('') - setIs2FALoading(false) + twoFAFetcher.submit(formData, { method: 'post' }) } if (isTwoFARequired) { diff --git a/web/app/pages/Dashboard/Dashboard.tsx b/web/app/pages/Dashboard/Dashboard.tsx index 2d8914543..6b2ce37b9 100644 --- a/web/app/pages/Dashboard/Dashboard.tsx +++ b/web/app/pages/Dashboard/Dashboard.tsx @@ -6,11 +6,11 @@ import _size from 'lodash/size' import { StretchHorizontalIcon, LayoutGridIcon, SearchIcon, XIcon, FolderPlusIcon } from 'lucide-react' import React, { useState, useEffect, useRef, useMemo, memo } from 'react' import { useTranslation } from 'react-i18next' -import { useLoaderData, useNavigate, useSearchParams } from 'react-router' +import { useLoaderData, useNavigate, useSearchParams, useFetcher } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { getProjects, getLiveVisitors, getOverallStats, createProject } from '~/api' +import { getProjects, getLiveVisitors, getOverallStats } from '~/api' import DashboardLockedBanner from '~/components/DashboardLockedBanner' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' import useBreakpoint from '~/hooks/useBreakpoint' @@ -18,7 +18,7 @@ import useDebounce from '~/hooks/useDebounce' import { isSelfhosted, LIVE_VISITORS_UPDATE_INTERVAL, tbPeriodPairs } from '~/lib/constants' import { Overall, Project } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' -import type { DashboardLoaderData } from '~/routes/dashboard' +import type { DashboardLoaderData, DashboardActionData } from '~/routes/dashboard' import Input from '~/ui/Input' import Modal from '~/ui/Modal' import Pagination from '~/ui/Pagination' @@ -48,6 +48,7 @@ const Dashboard = () => { const loaderData = useLoaderData() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() + const fetcher = useFetcher() const { user } = useAuth() @@ -105,7 +106,6 @@ const Dashboard = () => { const [newProjectModalOpen, setNewProjectModalOpen] = useState(false) const [newProjectName, setNewProjectName] = useState('') const [newProjectOrganisationId, setNewProjectOrganisationId] = useState(undefined) - const [isNewProjectLoading, setIsNewProjectLoading] = useState(false) const [newProjectError, setNewProjectError] = useState(null) const [newProjectBeenSubmitted, setNewProjectBeenSubmitted] = useState(false) @@ -203,11 +203,23 @@ const Dashboard = () => { return { errors, valid: _isEmpty(_keys(errors)) } } - const onCreateProject = async () => { - if (isNewProjectLoading) { - return + // Handle fetcher responses for create project + useEffect(() => { + if (fetcher.data?.success && fetcher.data?.intent === 'create-project') { + refetchProjects() + toast.success(t('project.settings.created')) + closeNewProjectModal() + } else if (fetcher.data?.error && fetcher.data?.intent === 'create-project') { + setNewProjectError(fetcher.data.error) + toast.error(fetcher.data.error) + } else if (fetcher.data?.fieldErrors?.name && fetcher.data?.intent === 'create-project') { + setNewProjectError(fetcher.data.fieldErrors.name) } + }, [fetcher.data, t]) // eslint-disable-line react-hooks/exhaustive-deps + + const isNewProjectLoading = fetcher.state === 'submitting' + const onCreateProject = () => { setNewProjectBeenSubmitted(true) const { errors, valid } = validateProjectName() @@ -216,33 +228,13 @@ const Dashboard = () => { return } - setIsNewProjectLoading(true) - - try { - await createProject({ - name: newProjectName || DEFAULT_PROJECT_NAME, - organisationId: newProjectOrganisationId, - }) - - await refetchProjects() - - toast.success(t('project.settings.created')) - closeNewProjectModal() - } catch (reason: unknown) { - console.error('[ERROR] Error while creating project:', reason) - - const normalizedMessage = - typeof reason === 'string' - ? reason - : (reason as any)?.response?.data?.message || - (reason as any)?.message || - t('apiNotifications.somethingWentWrong') - - setNewProjectError(normalizedMessage) - toast.error(normalizedMessage) - } finally { - setIsNewProjectLoading(false) + const formData = new FormData() + formData.set('intent', 'create-project') + formData.set('name', newProjectName || DEFAULT_PROJECT_NAME) + if (newProjectOrganisationId) { + formData.set('organisationId', newProjectOrganisationId) } + fetcher.submit(formData, { method: 'post' }) } const closeNewProjectModal = () => { diff --git a/web/app/pages/Onboarding/Onboarding.tsx b/web/app/pages/Onboarding/Onboarding.tsx index 4b2edaed1..8790cab30 100644 --- a/web/app/pages/Onboarding/Onboarding.tsx +++ b/web/app/pages/Onboarding/Onboarding.tsx @@ -4,13 +4,13 @@ import type { TFunction } from 'i18next' import { ChevronRightIcon, LaptopMinimalIcon, RocketIcon, CodeIcon, MailCheckIcon, SparklesIcon } from 'lucide-react' import { useState, useEffect, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { Link, useNavigate } from 'react-router' +import { Link, useNavigate, useFetcher } from 'react-router' import { toast } from 'sonner' -import { updateOnboardingStep, completeOnboarding, createProject, authMe, getProjects, deleteUser } from '~/api' +import { authMe, getProjects } from '~/api' import { CONTACT_US_URL } from '~/components/Footer' -import { withAuthentication, auth } from '~/hoc/protected' import { DOCS_URL, INTEGRATIONS_URL, isSelfhosted } from '~/lib/constants' +import type { OnboardingActionData } from '~/routes/onboarding' import { getSnippet } from '~/modals/TrackingSnippet' import { useAuth } from '~/providers/AuthProvider' import { Badge } from '~/ui/Badge' @@ -72,9 +72,9 @@ const Onboarding = () => { const { t } = useTranslation('common') const { user, loadUser, logout } = useAuth() const navigate = useNavigate() + const fetcher = useFetcher() const [currentStep, setCurrentStep] = useState(0) - const [isLoading, setIsLoading] = useState(false) const [projectName, setProjectName] = useState('') const [project, setProject] = useState(null) const [isWaitingForEvents, setIsWaitingForEvents] = useState(false) @@ -84,6 +84,37 @@ const Onboarding = () => { name?: string }>({}) + const isLoading = fetcher.state === 'submitting' + + // Handle fetcher responses + useEffect(() => { + if (fetcher.data?.success) { + const { intent, project: newProject, user: updatedUser } = fetcher.data + + if (intent === 'create-project' && newProject) { + setProject(newProject) + // Update the step after project creation + const stepFormData = new FormData() + stepFormData.set('intent', 'update-step') + stepFormData.set('step', 'setup_tracking') + fetcher.submit(stepFormData, { method: 'post' }) + setCurrentStep(2) + } else if (intent === 'update-step') { + loadUser() + } else if (intent === 'complete-onboarding') { + loadUser() + navigate(routes.dashboard) + } else if (intent === 'delete-account') { + logout() + navigate(routes.main) + } + } else if (fetcher.data?.error) { + toast.error(fetcher.data.error) + } else if (fetcher.data?.fieldErrors?.name) { + setNewProjectErrors({ name: fetcher.data.fieldErrors.name }) + } + }, [fetcher.data, loadUser, logout, navigate, fetcher]) + useEffect(() => { if (user?.hasCompletedOnboarding) { navigate(routes.dashboard) @@ -148,16 +179,14 @@ const Onboarding = () => { return () => clearInterval(interval) }, [isWaitingForEvents, project]) - const updateUserStep = async (step: string) => { - try { - await updateOnboardingStep(step) - await loadUser() - } catch (reason) { - console.error('Failed to update onboarding step:', reason) - } + const updateUserStep = (step: string) => { + const formData = new FormData() + formData.set('intent', 'update-step') + formData.set('step', step) + fetcher.submit(formData, { method: 'post' }) } - const handleCreateProject = async (e?: React.FormEvent) => { + const handleCreateProject = (e?: React.FormEvent) => { e?.preventDefault() e?.stopPropagation() @@ -173,36 +202,25 @@ const Onboarding = () => { return } - setIsLoading(true) - try { - const newProject = await createProject({ - name: trimmedName, - }) - - setProject(newProject) - await updateUserStep('setup_tracking') - setCurrentStep(2) - } catch (reason) { - toast.error(typeof reason === 'string' ? reason : 'Failed to create project') - } finally { - setIsLoading(false) - } + setNewProjectErrors({}) + const formData = new FormData() + formData.set('intent', 'create-project') + formData.set('name', trimmedName) + fetcher.submit(formData, { method: 'post' }) } - const handleCompleteOnboarding = async (skipped: boolean) => { - try { - await completeOnboarding() - await loadUser() + const handleCompleteOnboarding = (skipped: boolean) => { + trackCustom('ONBOARDING_COMPLETED', { skipped }) - trackCustom('ONBOARDING_COMPLETED', { - skipped, - }) + const formData = new FormData() + formData.set('intent', 'complete-onboarding') + fetcher.submit(formData, { method: 'post' }) + } - navigate(routes.dashboard) - } catch (reason) { - console.error('Failed to complete onboarding:', reason) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } + const handleDeleteAccount = () => { + const formData = new FormData() + formData.set('intent', 'delete-account') + fetcher.submit(formData, { method: 'post' }) } const steps = useMemo(() => { @@ -379,19 +397,7 @@ const Onboarding = () => { tabIndex={0} role='button' className='cursor-pointer font-medium text-indigo-600 hover:underline dark:text-indigo-400' - onClick={async () => { - try { - await deleteUser() - // Clear tokens synchronously before React state updates trigger redirects - removeAccessToken() - removeRefreshToken() - logout() - navigate(routes.signup) - } catch (error) { - console.error('Failed to delete account:', error) - toast.error(t('apiNotifications.somethingWentWrong')) - } - }} + onClick={handleDeleteAccount} /> ), }} @@ -649,4 +655,4 @@ const Onboarding = () => { ) } -export default withAuthentication(Onboarding, auth.authenticated) +export default Onboarding diff --git a/web/app/pages/Organisations/Organisations.tsx b/web/app/pages/Organisations/Organisations.tsx index 578541986..31fe597b4 100644 --- a/web/app/pages/Organisations/Organisations.tsx +++ b/web/app/pages/Organisations/Organisations.tsx @@ -5,16 +5,17 @@ import _size from 'lodash/size' import { SearchIcon, XIcon } from 'lucide-react' import React, { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { createOrganisation, getOrganisations } from '~/api' +import { getOrganisations } from '~/api' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' -import { withAuthentication, auth } from '~/hoc/protected' import useDebounce from '~/hooks/useDebounce' import { ENTRIES_PER_PAGE_DASHBOARD } from '~/lib/constants' import { DetailedOrganisation } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' +import type { OrganisationsActionData } from '~/routes/organisations._index' import Input from '~/ui/Input' import Loader from '~/ui/Loader' import Modal from '~/ui/Modal' @@ -29,6 +30,7 @@ import { OrganisationCard } from './OrganisationCard' const Organisations = () => { const { isLoading: authLoading } = useAuth() + const fetcher = useFetcher() const { t } = useTranslation('common') const [isSearchActive, setIsSearchActive] = useState(false) @@ -44,44 +46,42 @@ const Organisations = () => { const [newOrganisationName, setNewOrganisationName] = useState('') const [isLoading, setIsLoading] = useState(null) - const [isNewOrganisationLoading, setIsNewOrganisationLoading] = useState(false) const [error, setError] = useState(null) const [newOrganisationError, setNewOrganisationError] = useState(null) + const isNewOrganisationLoading = fetcher.state === 'submitting' + const pageAmount = Math.ceil(paginationTotal / ENTRIES_PER_PAGE_DASHBOARD) + // Handle fetcher responses + useEffect(() => { + if (fetcher.data?.success && fetcher.data?.intent === 'create-organisation') { + // Reload organisations + loadOrganisations(ENTRIES_PER_PAGE_DASHBOARD, (page - 1) * ENTRIES_PER_PAGE_DASHBOARD, debouncedSearch) + toast.success(t('apiNotifications.organisationCreated')) + closeNewOrganisationModal() + } else if (fetcher.data?.error && fetcher.data?.intent === 'create-organisation') { + setNewOrganisationError(fetcher.data.error) + toast.error(fetcher.data.error) + } else if (fetcher.data?.fieldErrors?.name && fetcher.data?.intent === 'create-organisation') { + setNewOrganisationError(fetcher.data.fieldErrors.name) + } + }, [fetcher.data, page, debouncedSearch, t]) // eslint-disable-line react-hooks/exhaustive-deps + const onNewOrganisation = () => { setNewOrganisationModalOpen(true) } - const onCreateOrganisation = async () => { - if (isNewOrganisationLoading) { + const onCreateOrganisation = () => { + if (fetcher.state === 'submitting') { return } - setIsNewOrganisationLoading(true) - - try { - await createOrganisation(newOrganisationName) - - const result = await getOrganisations( - ENTRIES_PER_PAGE_DASHBOARD, - (page - 1) * ENTRIES_PER_PAGE_DASHBOARD, - debouncedSearch, - ) - setOrganisations(result.results) - setPaginationTotal(result.total) - - toast.success(t('apiNotifications.organisationCreated')) - - closeNewOrganisationModal() - } catch (reason: any) { - setNewOrganisationError(reason) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.organisationCreateError')) - } finally { - setIsNewOrganisationLoading(false) - } + const formData = new FormData() + formData.set('intent', 'create-organisation') + formData.set('name', newOrganisationName) + fetcher.submit(formData, { method: 'post' }) } const closeNewOrganisationModal = () => { @@ -331,4 +331,4 @@ const Organisations = () => { ) } -export default withAuthentication(Organisations, auth.authenticated) +export default Organisations diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index a77df5b19..c35967852 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -4,13 +4,13 @@ import _size from 'lodash/size' import { Trash2Icon } from 'lucide-react' import React, { useState, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useNavigate } from 'react-router' +import { Link, useNavigate, useFetcher } from 'react-router' import { toast } from 'sonner' -import { transferProject, getOrganisation, updateOrganisation, deleteOrganisation } from '~/api' -import { withAuthentication, auth } from '~/hoc/protected' +import { getOrganisation } from '~/api' import { useRequiredParams } from '~/hooks/useRequiredParams' import { TITLE_SUFFIX } from '~/lib/constants' +import type { OrganisationSettingsActionData } from '~/routes/organisations.$id' import { DetailedOrganisation } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' import Button from '~/ui/Button' @@ -33,6 +33,7 @@ const OrganisationSettings = () => { const { t } = useTranslation('common') const { id } = useRequiredParams<{ id: string }>() const navigate = useNavigate() + const fetcher = useFetcher() const { user, isLoading: authLoading } = useAuth() @@ -42,8 +43,6 @@ const OrganisationSettings = () => { }>({}) const [beenSubmitted, setBeenSubmitted] = useState(false) const [showDelete, setShowDelete] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const [isSaving, setIsSaving] = useState(false) const [showTransfer, setShowTransfer] = useState(false) const [transferEmail, setTransferEmail] = useState('') @@ -55,6 +54,33 @@ const OrganisationSettings = () => { name: '', }) + const isSaving = fetcher.state === 'submitting' + const isDeleting = fetcher.state === 'submitting' && fetcher.formData?.get('intent') === 'delete-organisation' + + // Handle fetcher responses + useEffect(() => { + if (fetcher.data?.success) { + const { intent, organisation: updatedOrg } = fetcher.data + + if (intent === 'update-organisation') { + if (updatedOrg) { + setOrganisation(updatedOrg) + } + toast.success(t('apiNotifications.orgSettingsUpdated')) + } else if (intent === 'delete-organisation') { + toast.success(t('apiNotifications.organisationDeleted')) + setShowDelete(false) + navigate(routes.organisations) + } else if (intent === 'invite-member' || intent === 'remove-member' || intent === 'update-member-role') { + reloadOrganisation() + toast.success(t('apiNotifications.orgSettingsUpdated')) + } + } else if (fetcher.data?.error) { + toast.error(fetcher.data.error) + setShowDelete(false) + } + }, [fetcher.data, t, navigate]) // eslint-disable-line react-hooks/exhaustive-deps + const isOrganisationOwner = useMemo(() => { if (!organisation) { return false @@ -106,40 +132,21 @@ const OrganisationSettings = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [authLoading, id]) - const onSubmit = async (data: any) => { - if (isSaving) { - return - } + const onSubmit = (formData: Pick) => { + if (fetcher.state === 'submitting') return - setIsSaving(true) - - try { - await updateOrganisation(id, { name: data.name }) - toast.success(t('apiNotifications.orgSettingsUpdated')) - } catch (reason: any) { - toast.error(reason) - } finally { - setIsSaving(false) - } + const fd = new FormData() + fd.set('intent', 'update-organisation') + fd.set('name', formData.name) + fetcher.submit(fd, { method: 'post' }) } - const onDelete = async () => { - if (isDeleting) { - return - } - - setIsDeleting(true) + const onDelete = () => { + if (fetcher.state === 'submitting') return - try { - await deleteOrganisation(id) - toast.success(t('apiNotifications.organisationDeleted')) - navigate(routes.organisations) - } catch (reason: any) { - toast.error(reason) - } finally { - setIsDeleting(false) - setShowDelete(false) - } + const fd = new FormData() + fd.set('intent', 'delete-organisation') + fetcher.submit(fd, { method: 'post' }) } const validate = () => { @@ -323,4 +330,4 @@ const OrganisationSettings = () => { ) } -export default withAuthentication(OrganisationSettings, auth.authenticated) +export default OrganisationSettings diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index f892eb2f0..606898360 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -8,7 +8,6 @@ import _keys from 'lodash/keys' import _map from 'lodash/map' import _replace from 'lodash/replace' import _size from 'lodash/size' -import _split from 'lodash/split' import _toUpper from 'lodash/toUpper' import { Settings2Icon, @@ -25,31 +24,23 @@ import { } from 'lucide-react' import React, { useState, useEffect, useMemo, useCallback } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { useLoaderData, useNavigate, Link, useSearchParams } from 'react-router' +import { useLoaderData, useNavigate, Link, useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' import { - updateProject, - deleteProject, - resetProject, - transferProject, - deletePartially, getFilters, - resetFilters, getProject, - assignProjectToOrganisation, generateGSCAuthURL, getGSCStatus, getGSCProperties, setGSCProperty, disconnectGSC, - reGenerateCaptchaSecretKey, } from '~/api' -import { withAuthentication, auth } from '~/hoc/protected' import { useRequiredParams } from '~/hooks/useRequiredParams' import { isSelfhosted, TITLE_SUFFIX, FILTERS_PANELS_ORDER, isBrowser } from '~/lib/constants' import { Project } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' +import type { ProjectSettingsActionData } from '~/routes/projects.settings.$id' import Button from '~/ui/Button' import DatePicker from '~/ui/Datepicker' import Dropdown from '~/ui/Dropdown' @@ -66,7 +57,6 @@ import countries from '~/utils/isoCountries' import routes from '~/utils/routes' import CCRow from '../View/components/CCRow' -import { getFormatDate } from '../View/ViewProject.helpers' import Annotations from './Annotations' import Emails from './Emails' @@ -285,6 +275,7 @@ const ProjectSettings = () => { const { id } = useRequiredParams<{ id: string }>() const navigate = useNavigate() const { requestOrigin } = useLoaderData<{ requestOrigin: string | null }>() + const fetcher = useFetcher() const [project, setProject] = useState(null) const [form, setForm] = useState

({ @@ -393,7 +384,6 @@ const ProjectSettings = () => { const [captchaSecretKey, setCaptchaSecretKey] = useState(null) const [captchaDifficulty, setCaptchaDifficulty] = useState(4) const [showRegenerateSecret, setShowRegenerateSecret] = useState(false) - const [isSavingDifficulty, setIsSavingDifficulty] = useState(false) // for reset data via filters const [activeFilter, setActiveFilter] = useState([]) @@ -425,13 +415,11 @@ const ProjectSettings = () => { [user?.organisationMemberships, t], ) - const assignOrganisation = async (organisationId?: string) => { - try { - await assignProjectToOrganisation(id, organisationId) - toast.success(t('apiNotifications.projectAssigned')) - } catch (reason: any) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.projectAssignError')) - } + const assignOrganisation = async (organisationId?: string): Promise => { + const formData = new FormData() + formData.set('intent', 'assign-organisation') + if (organisationId) formData.set('organisationId', organisationId) + fetcher.submit(formData, { method: 'post' }) } const sharableLink = useMemo(() => { @@ -503,93 +491,134 @@ const ProjectSettings = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [authLoading, id]) - const onSubmit = async (data: Form) => { - if (!isSaving) { - setIsSaving(true) - try { - const formalisedData = { - ...data, - origins: _isEmpty(data.origins) - ? null - : _map(_split(data.origins, ','), (origin) => { - try { - if (_includes(origin, 'localhost')) { - return origin - } - return new URL(origin).host - } catch { - return origin - } - }), - ipBlacklist: _isEmpty(data.ipBlacklist) ? null : _split(data.ipBlacklist, ','), - countryBlacklist: _isEmpty(data.countryBlacklist) ? null : data.countryBlacklist, + // Handle fetcher responses + useEffect(() => { + if (fetcher.data?.success) { + const { intent, project: updatedProject } = fetcher.data + + if (intent === 'update-project') { + if (updatedProject) { + // Merge with existing project to preserve fields like 'role' that aren't returned from update + setProject((prev) => (prev ? { ...prev, ...updatedProject } : updatedProject)) } - await updateProject(id, formalisedData as Partial) + setBeenSubmitted(false) toast.success(t('project.settings.updated')) - } catch (reason: any) { - toast.error(reason) - } finally { - setIsSaving(false) + } else if (intent === 'delete-project') { + toast.success(t('project.settings.deleted')) + navigate(routes.dashboard) + } else if (intent === 'reset-project' || intent === 'delete-partially' || intent === 'reset-filters') { + toast.success(t('project.settings.resetSuccess')) + setShowReset(false) + } else if (intent === 'transfer-project') { + toast.success(t('apiNotifications.transferRequestSent')) + setShowTransfer(false) + setTransferEmail('') + } else if (intent === 'regenerate-captcha-key' && updatedProject?.captchaSecretKey) { + setCaptchaSecretKey(updatedProject.captchaSecretKey) + toast.success(t('project.settings.updated')) + } else if (intent === 'assign-organisation') { + toast.success(t('apiNotifications.projectAssigned')) } + + setIsSaving(false) + setIsDeleting(false) + setIsResetting(false) + } else if (fetcher.data?.error) { + toast.error(fetcher.data.error) + setIsSaving(false) + setIsDeleting(false) + setIsResetting(false) } + }, [fetcher.data, t, navigate]) + + const onSubmit = (data: Form) => { + if (fetcher.state === 'submitting') return + + setIsSaving(true) + const formData = new FormData() + formData.set('intent', 'update-project') + if (data.name) formData.set('name', data.name) + formData.set('public', data.public ? 'true' : 'false') + formData.set('isPasswordProtected', data.isPasswordProtected ? 'true' : 'false') + if (data.password) formData.set('password', data.password) + if (data.origins !== null) formData.set('origins', data.origins) + if (data.ipBlacklist !== null) formData.set('ipBlacklist', data.ipBlacklist) + if (data.botsProtectionLevel) formData.set('botsProtectionLevel', data.botsProtectionLevel) + if (data.countryBlacklist) formData.set('countryBlacklist', JSON.stringify(data.countryBlacklist)) + if (data.websiteUrl !== undefined) formData.set('websiteUrl', data.websiteUrl || '') + + fetcher.submit(formData, { method: 'post' }) } - const onDelete = async () => { + const onDelete = () => { setShowDelete(false) - if (isDeleting) { - return - } + if (fetcher.state === 'submitting') return setIsDeleting(true) - try { - await deleteProject(id) - toast.success(t('project.settings.deleted')) - navigate(routes.dashboard) - } catch (reason: any) { - toast.error(reason) - } finally { - setIsDeleting(false) - } + const formData = new FormData() + formData.set('intent', 'delete-project') + fetcher.submit(formData, { method: 'post' }) } - const onReset = async () => { - setShowReset(false) + const onReset = (resetTab: string, dateRange?: Date[], filterType?: string, filterValue?: string) => { + if (fetcher.state === 'submitting') return - if (setResetting) { - return + setIsResetting(true) + const formData = new FormData() + + if (resetTab === 'all') { + formData.set('intent', 'reset-project') + } else if (resetTab === 'partially' && dateRange) { + formData.set('intent', 'delete-partially') + formData.set('from', dateRange[0]?.toISOString() || '') + formData.set('to', dateRange[1]?.toISOString() || '') + } else if (resetTab === 'viaFilters' && filterType && filterValue) { + formData.set('intent', 'reset-filters') + formData.set('type', filterType) + formData.set('value', filterValue) } - setIsResetting(true) + fetcher.submit(formData, { method: 'post' }) + } - try { - if (tab === DELETE_DATA_MODAL_TABS[1].name) { - if (_isEmpty(dateRange)) { - toast.error(t('project.settings.noDateRange')) - setIsResetting(false) - return - } - await deletePartially(id, { - from: getFormatDate(dateRange[0]), - to: getFormatDate(dateRange[1]), - }) - } else if (tab === DELETE_DATA_MODAL_TABS[2].name) { - if (_isEmpty(activeFilter)) { - toast.error(t('project.settings.noFilters')) - setIsResetting(false) - return - } + const onTransfer = () => { + if (fetcher.state === 'submitting') return - await resetFilters(id, filterType, activeFilter) - } else { - await resetProject(id) + const formData = new FormData() + formData.set('intent', 'transfer-project') + formData.set('email', transferEmail) + fetcher.submit(formData, { method: 'post' }) + } + + const onRegenerateCaptchaKey = () => { + if (fetcher.state === 'submitting') return + + const formData = new FormData() + formData.set('intent', 'regenerate-captcha-key') + fetcher.submit(formData, { method: 'post' }) + } + + // Wrapper for reset that handles tab logic + const handleReset = () => { + setShowReset(false) + + if (fetcher.state === 'submitting') return + + if (tab === DELETE_DATA_MODAL_TABS[1].name) { + if (_isEmpty(dateRange)) { + toast.error(t('project.settings.noDateRange')) + return } - toast.success(t('project.settings.projectReset')) - navigate(routes.dashboard) - } catch (reason: any) { - toast.error(reason) - } finally { - setIsResetting(false) + onReset('partially', dateRange) + } else if (tab === DELETE_DATA_MODAL_TABS[2].name) { + if (_isEmpty(activeFilter)) { + toast.error(t('project.settings.noFilters')) + return + } + onReset('viaFilters', undefined, filterType, activeFilter[0]) + } else { + onReset('all') } } @@ -660,21 +689,6 @@ const ProjectSettings = () => { } } - const onTransfer = async () => { - await transferProject(id, transferEmail) - .then(() => { - toast.success(t('apiNotifications.transferRequestSent')) - navigate(routes.dashboard) - }) - .catch((reason) => { - toast.error(reason) - }) - .finally(() => { - setShowTransfer(false) - setTransferEmail('') - }) - } - const onProtected = async () => { setBeenSubmitted(true) @@ -1070,19 +1084,12 @@ const ProjectSettings = () => { @@ -1145,7 +1139,7 @@ const ProjectSettings = () => { /> setShowReset(false)} - onSubmit={onReset} + onSubmit={handleReset} size='large' submitText={t('project.settings.reset')} closeText={t('common.close')} @@ -1225,15 +1219,9 @@ const ProjectSettings = () => { /> setShowRegenerateSecret(false)} - onSubmit={async () => { - try { - const newKey = await reGenerateCaptchaSecretKey(id) - setCaptchaSecretKey(newKey) - setShowRegenerateSecret(false) - toast.success(t('project.settings.captcha.keyRegenerated')) - } catch (reason: any) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } + onSubmit={() => { + setShowRegenerateSecret(false) + onRegenerateCaptchaKey() }} submitText={t('project.settings.captcha.regenerateKey')} closeText={t('common.cancel')} @@ -1247,4 +1235,4 @@ const ProjectSettings = () => { ) } -export default withAuthentication(ProjectSettings, auth.authenticated) +export default ProjectSettings diff --git a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx index 5585b5170..a0cca41c8 100644 --- a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx @@ -4,9 +4,11 @@ import _map from 'lodash/map' import { Trash2Icon, PlusIcon } from 'lucide-react' import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { getGoal, createGoal, updateGoal, type CreateGoal } from '~/api' +import { getGoal } from '~/api' +import type { ProjectViewActionData } from '~/routes/projects.$id' import Button from '~/ui/Button' import Input from '~/ui/Input' import Loader from '~/ui/Loader' @@ -32,10 +34,10 @@ interface GoalSettingsModalProps { const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: GoalSettingsModalProps) => { const { t } = useTranslation() + const fetcher = useFetcher() const isNew = !goalId const [isLoading, setIsLoading] = useState(false) - const [isSaving, setIsSaving] = useState(false) // Form state const [name, setName] = useState('') @@ -44,6 +46,8 @@ const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: Go const [value, setValue] = useState('') const [metadataFilters, setMetadataFilters] = useState<{ key: string; value: string }[]>([]) + const isSaving = fetcher.state === 'submitting' + const resetForm = () => { setName('') setType('pageview') @@ -81,39 +85,36 @@ const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: Go // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, goalId]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setIsSaving(true) - - try { - if (isNew) { - const data: CreateGoal = { - pid: projectId, - name, - type, - matchType, - value: value || undefined, - metadataFilters: metadataFilters.length > 0 ? metadataFilters : undefined, - } - await createGoal(data) + // Handle fetcher responses + useEffect(() => { + if (fetcher.data?.success) { + const { intent } = fetcher.data + if (intent === 'create-goal') { toast.success(t('goals.created')) - } else if (goalId) { - await updateGoal(goalId, { - name, - type, - matchType, - value: value || null, - metadataFilters: metadataFilters.length > 0 ? metadataFilters : null, - }) + } else if (intent === 'update-goal') { toast.success(t('goals.updated')) } onSuccess() onClose() - } catch (err: any) { - toast.error(err?.message || t('apiNotifications.somethingWentWrong')) - } finally { - setIsSaving(false) + } else if (fetcher.data?.error) { + toast.error(fetcher.data.error) } + }, [fetcher.data, t, onSuccess, onClose]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + const formData = new FormData() + formData.set('intent', isNew ? 'create-goal' : 'update-goal') + formData.set('name', name) + formData.set('goalType', type) + formData.set('goalValue', value) + + if (!isNew && goalId) { + formData.set('goalId', goalId) + } + + fetcher.submit(formData, { method: 'post' }) } const addMetadataFilter = () => { diff --git a/web/app/pages/Project/tabs/Goals/GoalsView.tsx b/web/app/pages/Project/tabs/Goals/GoalsView.tsx index 602111077..bcac9b782 100644 --- a/web/app/pages/Project/tabs/Goals/GoalsView.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalsView.tsx @@ -19,11 +19,10 @@ import { } from 'lucide-react' import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useSearchParams } from 'react-router' +import { useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' import { - deleteGoal as deleteGoalApi, getProjectGoals, getGoalStats, getGoalChart, @@ -32,6 +31,7 @@ import { type GoalStats, type GoalChartData, } from '~/api' +import type { ProjectViewActionData } from '~/routes/projects.$id' import { TimeFormat, tbsFormatMapper, @@ -433,6 +433,7 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => const [searchParams] = useSearchParams() const isEmbedded = searchParams.get('embedded') === 'true' const { t } = useTranslation() + const fetcher = useFetcher() const [isLoading, setIsLoading] = useState(null) const isLoadingRef = useRef(false) // Use ref to avoid stale closure issues in loadGoals @@ -620,15 +621,21 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) } - const handleDeleteGoal = async (goalId: string) => { - try { - await deleteGoalApi(goalId) + // Handle fetcher responses for delete + useEffect(() => { + if (fetcher.data?.success && fetcher.data?.intent === 'delete-goal') { toast.success(t('goals.deleted')) - // Reload goals loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) - } catch (reason: any) { - toast.error(reason?.response?.data?.message || reason?.message || t('apiNotifications.somethingWentWrong')) + } else if (fetcher.data?.error && fetcher.data?.intent === 'delete-goal') { + toast.error(fetcher.data.error) } + }, [fetcher.data, t, page, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleDeleteGoal = (goalId: string) => { + const formData = new FormData() + formData.set('intent', 'delete-goal') + formData.set('goalId', goalId) + fetcher.submit(formData, { method: 'post' }) } if (error && isLoading === false) { diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index e4ef96a8e..04c0177ab 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -12,24 +12,15 @@ import _size from 'lodash/size' import { MessageSquareTextIcon, MonitorIcon, UserRoundIcon } from 'lucide-react' import React, { useState, useEffect, memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router' +import { useNavigate, useFetcher } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { - confirmEmail, - generateApiKey, - deleteApiKey, - receiveLoginNotification, - setShowLiveVisitorsInTitle, - changeUserDetails, - deleteUser, -} from '~/api' -import { withAuthentication, auth } from '~/hoc/protected' import { reportFrequencies, DEFAULT_TIMEZONE, CONFIRMATION_TIMEOUT, TimeFormat, isSelfhosted } from '~/lib/constants' import { User } from '~/lib/models/User' import PaidFeature from '~/modals/PaidFeature' import { useAuth } from '~/providers/AuthProvider' +import type { UserSettingsActionData } from '~/routes/user-settings' import Button from '~/ui/Button' import Checkbox from '~/ui/Checkbox' import Input from '~/ui/Input' @@ -103,10 +94,12 @@ interface Form extends Partial { } const UserSettings = () => { - const { user, isLoading, logout, setUser, mergeUser } = useAuth() + const { user, isLoading, logout, mergeUser } = useAuth() const navigate = useNavigate() const { t } = useTranslation('common') + const fetcher = useFetcher() + const [activeTab, setActiveTab] = useState(TAB_MAPPING.ACCOUNT) const [form, setForm] = useState({ email: user?.email || '', @@ -128,12 +121,50 @@ const UserSettings = () => { const [error, setError] = useState(null) const translatedFrequencies = _map(reportFrequencies, (key) => t(`profileSettings.${key}`)) const translatedTimeFormat = _map(TimeFormat, (key) => t(`profileSettings.${key}`)) - const [settingUpdating, setSettingUpdating] = useState(false) const [deletionFeedback, setDeletionFeedback] = useState('') + const isSubmitting = fetcher.state === 'submitting' + const tabs = getTabs(t) const activeTabLabel = useMemo(() => _find(tabs, (tab) => tab.id === activeTab)?.label, [tabs, activeTab]) + // Handle fetcher responses + useEffect(() => { + if (fetcher.data?.success) { + const { intent, user: updatedUser, apiKey } = fetcher.data + + if (intent === 'update-profile' && updatedUser) { + mergeUser(updatedUser) + toast.success(t('profileSettings.updated')) + + // If password was changed, log out the user + if (form.password) { + logout() + } + } else if (intent === 'generate-api-key' && apiKey) { + mergeUser({ apiKey }) + toast.success(t('profileSettings.updated')) + } else if (intent === 'delete-api-key') { + mergeUser({ apiKey: null }) + toast.success(t('profileSettings.updated')) + } else if (intent === 'toggle-live-visitors' && updatedUser) { + mergeUser(updatedUser) + toast.success(t('profileSettings.updated')) + } else if (intent === 'toggle-login-notifications') { + toast.success(t('profileSettings.updated')) + } else if (intent === 'confirm-email') { + setCookie(CONFIRMATION_TIMEOUT, true, 600) + toast.success(t('profileSettings.confSent')) + } else if (intent === 'delete-account') { + logout() + toast.success(t('apiNotifications.accountDeleted')) + navigate(routes.main) + } + } else if (fetcher.data?.error) { + toast.error(fetcher.data.error) + } + }, [fetcher.data, mergeUser, t, logout, navigate, form.password]) + const validate = () => { const allErrors = {} as Record @@ -157,26 +188,6 @@ const UserSettings = () => { setValidated(valid) } - const onSubmit = async (data: any, callback: (isSuccess: boolean) => void = () => {}) => { - delete data.repeat - - for (const key in data) { - if (data[key] === '') { - delete data[key] - } - } - - try { - const result = await changeUserDetails(data) - setUser(result) - toast.success(t('profileSettings.updated')) - } catch (reason: any) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } finally { - callback(true) - } - } - useEffect(() => { validate() }, [form]) // eslint-disable-line @@ -206,11 +217,26 @@ const UserSettings = () => { })) } - const handleSubmit = ( - e: React.FormEvent | null, - force?: boolean, - callback: (isSuccess: boolean) => void = () => {}, - ) => { + const submitProfileUpdate = (additionalData?: Record) => { + setBeenSubmitted(true) + + if (!validated) return + + const formData = new FormData() + formData.set('intent', 'update-profile') + if (form.email) formData.set('email', form.email) + if (form.password) formData.set('password', form.password) + if (form.repeat) formData.set('repeat', form.repeat) + if (additionalData?.timezone) formData.set('timezone', additionalData.timezone as string) + if (additionalData?.timeFormat || form.timeFormat) { + formData.set('timeFormat', (additionalData?.timeFormat || form.timeFormat) as string) + } + if (additionalData?.reportFrequency) formData.set('reportFrequency', additionalData.reportFrequency as string) + + fetcher.submit(formData, { method: 'post' }) + } + + const handleSubmit = (e: React.FormEvent | null, force?: boolean) => { if (e) { e.preventDefault() e.stopPropagation() @@ -224,139 +250,77 @@ const UserSettings = () => { return } - onSubmit(form, callback) + submitProfileUpdate() } } const handleTimezoneSave = () => { - setBeenSubmitted(true) - - if (validated) { - onSubmit({ - ...form, - timezone, - }) - } + submitProfileUpdate({ timezone }) } - const handleShowLiveVisitorsSave = async (checked: boolean) => { - if (settingUpdating) { - return - } - - setSettingUpdating(true) - - try { - await setShowLiveVisitorsInTitle(checked) - mergeUser({ showLiveVisitorsInTitle: checked }) - toast.success(t('profileSettings.updated')) - } catch (reason: any) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } finally { - setSettingUpdating(false) - } + const handleShowLiveVisitorsSave = (checked: boolean) => { + const formData = new FormData() + formData.set('intent', 'toggle-live-visitors') + formData.set('show', checked.toString()) + fetcher.submit(formData, { method: 'post' }) + // Optimistic update + mergeUser({ showLiveVisitorsInTitle: checked }) } - const handleReceiveLoginNotifications = async (checked: boolean) => { - if (settingUpdating) { - return - } - - setSettingUpdating(true) - - try { - await receiveLoginNotification(checked) - mergeUser({ receiveLoginNotifications: checked }) - toast.success(t('profileSettings.updated')) - } catch (reason: any) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } finally { - setSettingUpdating(false) - } + const handleReceiveLoginNotifications = (checked: boolean) => { + const formData = new FormData() + formData.set('intent', 'toggle-login-notifications') + formData.set('receiveLoginNotifications', checked.toString()) + fetcher.submit(formData, { method: 'post' }) + // Optimistic update + mergeUser({ receiveLoginNotifications: checked }) } - const handleIntegrationSave = (data: any, callback: (isSuccess: boolean) => void = () => {}) => { + const handleIntegrationSave = (data: Record, callback: (isSuccess: boolean) => void = () => {}) => { setBeenSubmitted(true) if (validated) { - onSubmit( - { - ...form, - ...data, - }, - callback, - ) + submitProfileUpdate(data) + callback(true) } } const handleReportSave = () => { - setBeenSubmitted(true) - - if (validated) { - onSubmit({ - ...form, - reportFrequency, - }) - } + submitProfileUpdate({ reportFrequency }) } - const onAccountDelete = async () => { - try { - await deleteUser(deletionFeedback) - logout() - toast.success(t('apiNotifications.accountDeleted')) - navigate(routes.main) - } catch (reason: any) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } + const onAccountDelete = () => { + const formData = new FormData() + formData.set('intent', 'delete-account') + formData.set('feedback', deletionFeedback) + fetcher.submit(formData, { method: 'post' }) } - const onEmailConfirm = async (errorCallback: any) => { + const onEmailConfirm = () => { if (getCookie(CONFIRMATION_TIMEOUT)) { toast.error(t('profileSettings.confTimeout')) return } - try { - const res = await confirmEmail() - - if (res) { - setCookie(CONFIRMATION_TIMEOUT, true, 600) - toast.success(t('profileSettings.confSent')) - } else { - errorCallback(t('profileSettings.noConfLeft')) - } - } catch (reason: any) { - toast.error(reason) - } + const formData = new FormData() + formData.set('intent', 'confirm-email') + fetcher.submit(formData, { method: 'post' }) } - const onApiKeyGenerate = async () => { - try { - const { apiKey } = await generateApiKey() - mergeUser({ apiKey }) - } catch (reason: any) { - toast.error(reason) - } + const onApiKeyGenerate = () => { + const formData = new FormData() + formData.set('intent', 'generate-api-key') + fetcher.submit(formData, { method: 'post' }) } - const onApiKeyDelete = async () => { - try { - await deleteApiKey() - mergeUser({ apiKey: null }) - } catch (reason: any) { - toast.error(reason) - } + const onApiKeyDelete = () => { + const formData = new FormData() + formData.set('intent', 'delete-api-key') + fetcher.submit(formData, { method: 'post' }) } - const setAsyncTimeFormat = async () => { - setBeenSubmitted(true) - - if (validated) { - onSubmit({ - ...form, - }) - } + const setAsyncTimeFormat = () => { + submitProfileUpdate({ timeFormat: form.timeFormat }) } const toggleShowPasswordFields = () => { @@ -646,7 +610,7 @@ const UserSettings = () => { {!user?.isActive ? (
onEmailConfirm(setError)} + onClick={onEmailConfirm} > {t('profileSettings.noLink')} @@ -730,7 +694,7 @@ const UserSettings = () => { { { }} onSubmit={() => { setIsPasswordChangeModalOpened(false) - handleSubmit(null, true, (isSuccess: boolean) => { - // password has been changed, let's log out the user as all the sessions are now invalid - if (isSuccess) { - logout() - } - }) + handleSubmit(null, true) }} closeText={t('common.cancel')} submitText={t('common.continue')} @@ -890,4 +849,4 @@ const UserSettings = () => { ) } -export default memo(withAuthentication(UserSettings, auth.authenticated)) +export default memo(UserSettings) diff --git a/web/app/pages/UserSettings/components/TwoFA.tsx b/web/app/pages/UserSettings/components/TwoFA.tsx index d6a8f8f0a..469634d3f 100644 --- a/web/app/pages/UserSettings/components/TwoFA.tsx +++ b/web/app/pages/UserSettings/components/TwoFA.tsx @@ -1,20 +1,19 @@ import cx from 'clsx' import _isNull from 'lodash/isNull' -import _isString from 'lodash/isString' -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import QRCode from 'react-qr-code' import { toast } from 'sonner' -import { generate2FA, enable2FA, disable2FA } from '~/api' import { useAuth } from '~/providers/AuthProvider' +import type { UserSettingsActionData } from '~/routes/user-settings' import Button from '~/ui/Button' import Input from '~/ui/Input' -import { setAccessToken } from '~/utils/accessToken' -import { setRefreshToken } from '~/utils/refreshToken' const TwoFA = () => { const { user, mergeUser } = useAuth() + const fetcher = useFetcher() const { t } = useTranslation('common') const [twoFAConfigurating, setTwoFAConfigurating] = useState(false) @@ -22,89 +21,78 @@ const TwoFA = () => { const [twoFAConfigData, setTwoFAConfigData] = useState<{ secret?: string otpauthUrl?: string - }>({}) // { secret, otpauthUrl } - const [isTwoFaLoading, setIsTwoFaLoading] = useState(false) + }>({}) const [twoFACode, setTwoFACode] = useState('') const [twoFACodeError, setTwoFACodeError] = useState(null) const [twoFARecovery, setTwoFARecovery] = useState(null) const { isTwoFactorAuthenticationEnabled } = user || {} - const handle2FAInput = (event: React.ChangeEvent) => { - const { - target: { value }, - } = event - setTwoFACode(value) - setTwoFACodeError(null) - } + const isLoading = fetcher.state === 'submitting' - const _generate2FA = async () => { - if (!isTwoFaLoading) { - setIsTwoFaLoading(true) + // Handle fetcher responses + useEffect(() => { + if (fetcher.data?.success) { + const { intent, twoFAData } = fetcher.data - try { - const result = await generate2FA() + if (intent === 'generate-2fa' && twoFAData) { setTwoFAConfigurating(true) - setTwoFAConfigData(result) - } catch (reason) { - if (_isString(reason)) { - toast.error(reason) - } else { - toast.error(t('apiNotifications.generate2FAError')) - } - console.error(`[ERROR] Failed to generate 2FA: ${reason}`) - setTwoFAConfigurating(false) - setTwoFAConfigData({}) + setTwoFAConfigData({ secret: twoFAData.secret, otpauthUrl: twoFAData.otpauthUrl }) + } else if (intent === 'enable-2fa' && twoFAData?.twoFactorRecoveryCode) { + mergeUser({ isTwoFactorAuthenticationEnabled: true }) + setTwoFARecovery(twoFAData.twoFactorRecoveryCode) + setTwoFACode('') + } else if (intent === 'disable-2fa') { + mergeUser({ isTwoFactorAuthenticationEnabled: false }) + setTwoFADisabling(false) + setTwoFACode('') } + } else if (fetcher.data?.error) { + const { intent, error } = fetcher.data - setIsTwoFaLoading(false) - } - } - - const _enable2FA = async () => { - if (!isTwoFaLoading) { - setIsTwoFaLoading(true) - - try { - const { twoFactorRecoveryCode, accessToken, refreshToken } = await enable2FA(twoFACode) - setRefreshToken(refreshToken) - // TODO: Should probably pass dontRemember here if user session is temporary - setAccessToken(accessToken) - mergeUser({ isTwoFactorAuthenticationEnabled: true }) - setTwoFARecovery(twoFactorRecoveryCode) - } catch (reason) { - if (_isString(reason)) { - toast.error(reason) - } + if (intent === 'enable-2fa' || intent === 'disable-2fa') { setTwoFACodeError(t('profileSettings.invalid2fa')) + setTwoFACode('') + } else { + toast.error(error) + if (intent === 'generate-2fa') { + setTwoFAConfigurating(false) + setTwoFAConfigData({}) + } } - - setTwoFACode('') - setIsTwoFaLoading(false) } + }, [fetcher.data, mergeUser, t]) + + const handle2FAInput = (event: React.ChangeEvent) => { + const { + target: { value }, + } = event + setTwoFACode(value) + setTwoFACodeError(null) } - const _disable2FA = async () => { - if (!isTwoFaLoading) { - setIsTwoFaLoading(true) + const _generate2FA = () => { + const formData = new FormData() + formData.set('intent', 'generate-2fa') + fetcher.submit(formData, { method: 'post' }) + } - try { - await disable2FA(twoFACode) - mergeUser({ isTwoFactorAuthenticationEnabled: false }) - } catch (reason) { - if (_isString(reason)) { - toast.error(reason) - } - setTwoFACodeError(t('profileSettings.invalid2fa')) - } + const _enable2FA = () => { + const formData = new FormData() + formData.set('intent', 'enable-2fa') + formData.set('code', twoFACode) + fetcher.submit(formData, { method: 'post' }) + } - setTwoFACode('') - setIsTwoFaLoading(false) - } + const _disable2FA = () => { + const formData = new FormData() + formData.set('intent', 'disable-2fa') + formData.set('code', twoFACode) + fetcher.submit(formData, { method: 'post' }) } const callFnOnKeyPress = - (fn: (e: any) => void, key = 'Enter') => - (e: any) => { + (fn: (e: React.KeyboardEvent) => void, key = 'Enter') => + (e: React.KeyboardEvent) => { e.stopPropagation() if (e.key === key) { fn(e) @@ -142,7 +130,7 @@ const TwoFA = () => { onChange={handle2FAInput} onKeyDown={callFnOnKeyPress(_disable2FA)} error={twoFACodeError} - disabled={isTwoFaLoading} + disabled={isLoading} /> diff --git a/web/app/routes/dashboard.tsx b/web/app/routes/dashboard.tsx index 8ea26ed2f..cfc30aa7d 100644 --- a/web/app/routes/dashboard.tsx +++ b/web/app/routes/dashboard.tsx @@ -1,4 +1,4 @@ -import type { LoaderFunctionArgs } from 'react-router' +import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router' import { data } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' @@ -20,6 +20,64 @@ export interface DashboardLoaderData { } | null } +export interface DashboardActionData { + success?: boolean + intent?: string + error?: string + fieldErrors?: { + name?: string + } + project?: Project +} + +export async function action({ request }: ActionFunctionArgs) { + redirectIfNotAuthenticated(request) + + const formData = await request.formData() + const intent = formData.get('intent')?.toString() + + switch (intent) { + case 'create-project': { + const name = formData.get('name')?.toString() || '' + const organisationId = formData.get('organisationId')?.toString() + + if (!name.trim()) { + return data( + { intent, fieldErrors: { name: 'Project name is required' } }, + { status: 400 }, + ) + } + + if (name.length > 50) { + return data( + { intent, fieldErrors: { name: 'Project name must be 50 characters or less' } }, + { status: 400 }, + ) + } + + const body: { name: string; organisationId?: string } = { name: name.trim() } + if (organisationId) body.organisationId = organisationId + + const result = await serverFetch(request, 'project', { + method: 'POST', + body, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, project: result.data as Project }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + default: + return data({ error: 'Unknown action' }, { status: 400 }) + } +} + export async function loader({ request }: LoaderFunctionArgs) { redirectIfNotAuthenticated(request) diff --git a/web/app/routes/login.tsx b/web/app/routes/login.tsx index f50ecd273..b4d38f0be 100644 --- a/web/app/routes/login.tsx +++ b/web/app/routes/login.tsx @@ -1,9 +1,9 @@ import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from 'react-router' import { redirect, data } from 'react-router' -import { getAuthenticatedUser, loginUser } from '~/api/api.server' +import { getAuthenticatedUser, loginUser, serverFetch } from '~/api/api.server' import Signin from '~/pages/Auth/Signin' -import { createHeadersWithCookies } from '~/utils/session.server' +import { createHeadersWithCookies, createAuthCookies } from '~/utils/session.server' export const headers: HeadersFunction = ({ parentHeaders }) => { parentHeaders.set('X-Frame-Options', 'DENY') @@ -26,15 +26,51 @@ export async function loader({ request }: LoaderFunctionArgs) { export interface LoginActionData { error?: string | string[] requires2FA?: boolean + twoFASuccess?: boolean fieldErrors?: { email?: string password?: string + twoFACode?: string } } export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData() + const intent = formData.get('intent')?.toString() + // Handle 2FA submission + if (intent === 'submit-2fa') { + const twoFactorAuthenticationCode = formData.get('twoFACode')?.toString() || '' + const dontRemember = formData.get('dontRemember') === 'true' + + if (!twoFactorAuthenticationCode || twoFactorAuthenticationCode.length !== 6) { + return data({ fieldErrors: { twoFACode: 'Please enter a valid 6-digit code' } }, { status: 400 }) + } + + const result = await serverFetch<{ accessToken: string; refreshToken: string; user: { hasCompletedOnboarding: boolean } }>( + request, + '2fa/authenticate', + { + method: 'POST', + body: { twoFactorAuthenticationCode }, + }, + ) + + if (result.error) { + return data({ fieldErrors: { twoFACode: 'Invalid 2FA code' } }, { status: 400 }) + } + + const { accessToken, refreshToken, user } = result.data! + const cookies = createAuthCookies({ accessToken, refreshToken }, !dontRemember) + + const redirectTo = user.hasCompletedOnboarding ? '/dashboard' : '/onboarding' + + return redirect(redirectTo, { + headers: createHeadersWithCookies(cookies), + }) + } + + // Handle regular login const email = formData.get('email')?.toString() || '' const password = formData.get('password')?.toString() || '' const dontRemember = formData.get('dontRemember') === 'true' diff --git a/web/app/routes/onboarding.tsx b/web/app/routes/onboarding.tsx index 1ebcb2989..72eb190af 100644 --- a/web/app/routes/onboarding.tsx +++ b/web/app/routes/onboarding.tsx @@ -1,7 +1,12 @@ -import type { MetaFunction } from 'react-router' +import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from 'react-router' +import { data } from 'react-router' import { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' +import { Project } from '~/lib/models/Project' +import { User } from '~/lib/models/User' import Onboarding from '~/pages/Onboarding' +import { redirectIfNotAuthenticated, createHeadersWithCookies } from '~/utils/session.server' export const sitemap: SitemapFunction = () => ({ exclude: true, @@ -14,6 +19,114 @@ export const meta: MetaFunction = () => { ] } +export async function loader({ request }: LoaderFunctionArgs) { + redirectIfNotAuthenticated(request) + return null +} + +export interface OnboardingActionData { + success?: boolean + intent?: string + error?: string + fieldErrors?: { + name?: string + } + project?: Project + user?: User +} + +export async function action({ request }: ActionFunctionArgs) { + redirectIfNotAuthenticated(request) + + const formData = await request.formData() + const intent = formData.get('intent')?.toString() + + switch (intent) { + case 'create-project': { + const name = formData.get('name')?.toString() || '' + + if (!name.trim()) { + return data( + { intent, fieldErrors: { name: 'Project name is required' } }, + { status: 400 }, + ) + } + + if (name.length > 50) { + return data( + { intent, fieldErrors: { name: 'Project name must be 50 characters or less' } }, + { status: 400 }, + ) + } + + const result = await serverFetch(request, 'project', { + method: 'POST', + body: { name }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, project: result.data as Project }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-step': { + const step = formData.get('step')?.toString() || '' + + const result = await serverFetch(request, 'user/onboarding/step', { + method: 'POST', + body: { step }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, user: result.data as User }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'complete-onboarding': { + const result = await serverFetch(request, 'user/onboarding/complete', { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, user: result.data as User }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-account': { + const result = await serverFetch(request, 'user', { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + default: + return data({ error: 'Unknown action' }, { status: 400 }) + } +} + export default function OnboardingRoute() { return } diff --git a/web/app/routes/organisations.$id.tsx b/web/app/routes/organisations.$id.tsx index fac9422d6..94b167e03 100644 --- a/web/app/routes/organisations.$id.tsx +++ b/web/app/routes/organisations.$id.tsx @@ -1,21 +1,163 @@ -import { redirect } from 'react-router' +import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router' +import { data, redirect } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' +import { DetailedOrganisation } from '~/lib/models/Organisation' import OrganisationSettings from '~/pages/Organisations/Settings' +import { redirectIfNotAuthenticated, createHeadersWithCookies } from '~/utils/session.server' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export async function loader() { +export async function loader({ request }: LoaderFunctionArgs) { if (isSelfhosted) { return redirect('/login', 302) } + redirectIfNotAuthenticated(request) return null } +export interface OrganisationSettingsActionData { + success?: boolean + intent?: string + error?: string + fieldErrors?: { + name?: string + email?: string + } + organisation?: DetailedOrganisation +} + +export async function action({ request, params }: ActionFunctionArgs) { + if (isSelfhosted) { + return redirect('/login', 302) + } + + redirectIfNotAuthenticated(request) + + const { id } = params + const formData = await request.formData() + const intent = formData.get('intent')?.toString() + + switch (intent) { + case 'update-organisation': { + const name = formData.get('name')?.toString() || '' + + if (!name.trim()) { + return data( + { intent, fieldErrors: { name: 'Organisation name is required' } }, + { status: 400 }, + ) + } + + if (name.length > 50) { + return data( + { intent, fieldErrors: { name: 'Organisation name must be 50 characters or less' } }, + { status: 400 }, + ) + } + + const result = await serverFetch(request, `organisation/${id}`, { + method: 'PATCH', + body: { name: name.trim() }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, organisation: result.data as DetailedOrganisation }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-organisation': { + const result = await serverFetch(request, `organisation/${id}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'invite-member': { + const email = formData.get('email')?.toString() || '' + const role = formData.get('role')?.toString() || 'viewer' + + if (!email || !email.includes('@')) { + return data( + { intent, fieldErrors: { email: 'Please enter a valid email address' } }, + { status: 400 }, + ) + } + + const result = await serverFetch(request, `organisation/${id}/members`, { + method: 'POST', + body: { email, role }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'remove-member': { + const memberId = formData.get('memberId')?.toString() + + const result = await serverFetch(request, `organisation/${id}/members/${memberId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-member-role': { + const memberId = formData.get('memberId')?.toString() + const role = formData.get('role')?.toString() + + const result = await serverFetch(request, `organisation/${id}/members/${memberId}`, { + method: 'PATCH', + body: { role }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + default: + return data({ error: 'Unknown action' }, { status: 400 }) + } +} + export default function Index() { return } diff --git a/web/app/routes/organisations._index.tsx b/web/app/routes/organisations._index.tsx index 1b3fbee36..939241c7d 100644 --- a/web/app/routes/organisations._index.tsx +++ b/web/app/routes/organisations._index.tsx @@ -1,21 +1,84 @@ -import { redirect } from 'react-router' +import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router' +import { redirect, data } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' +import { DetailedOrganisation } from '~/lib/models/Organisation' import Organisations from '~/pages/Organisations' +import { redirectIfNotAuthenticated, createHeadersWithCookies } from '~/utils/session.server' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export async function loader() { +export async function loader({ request }: LoaderFunctionArgs) { if (isSelfhosted) { return redirect('/login', 302) } + redirectIfNotAuthenticated(request) return null } +export interface OrganisationsActionData { + success?: boolean + intent?: string + error?: string + fieldErrors?: { + name?: string + } + organisation?: DetailedOrganisation +} + +export async function action({ request }: ActionFunctionArgs) { + if (isSelfhosted) { + return redirect('/login', 302) + } + + redirectIfNotAuthenticated(request) + + const formData = await request.formData() + const intent = formData.get('intent')?.toString() + + switch (intent) { + case 'create-organisation': { + const name = formData.get('name')?.toString() || '' + + if (!name.trim()) { + return data( + { intent, fieldErrors: { name: 'Organisation name is required' } }, + { status: 400 }, + ) + } + + if (name.length > 50) { + return data( + { intent, fieldErrors: { name: 'Organisation name must be 50 characters or less' } }, + { status: 400 }, + ) + } + + const result = await serverFetch(request, 'organisation', { + method: 'POST', + body: { name: name.trim() }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, organisation: result.data as DetailedOrganisation }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + default: + return data({ error: 'Unknown action' }, { status: 400 }) + } +} + export default function Index() { return } diff --git a/web/app/routes/password-reset.$id.tsx b/web/app/routes/password-reset.$id.tsx index 7b4b3e1ac..e9192d16a 100644 --- a/web/app/routes/password-reset.$id.tsx +++ b/web/app/routes/password-reset.$id.tsx @@ -1,7 +1,10 @@ -import type { HeadersFunction } from 'react-router' +import type { ActionFunctionArgs, HeadersFunction } from 'react-router' +import { data, redirect } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' import CreateNewPassword from '~/pages/Auth/CreateNewPassword' +import { isValidPassword, MIN_PASSWORD_CHARS, MAX_PASSWORD_CHARS } from '~/utils/validator' export const headers: HeadersFunction = ({ parentHeaders }) => { parentHeaders.set('X-Frame-Options', 'DENY') @@ -12,6 +15,52 @@ export const sitemap: SitemapFunction = () => ({ exclude: true, }) +export interface CreateNewPasswordActionData { + success?: boolean + error?: string + fieldErrors?: { + password?: string + repeat?: string + } +} + +export async function action({ request, params }: ActionFunctionArgs) { + const { id } = params + const formData = await request.formData() + const password = formData.get('password')?.toString() || '' + const repeat = formData.get('repeat')?.toString() || '' + + const fieldErrors: CreateNewPasswordActionData['fieldErrors'] = {} + + if (!isValidPassword(password)) { + fieldErrors.password = `Password must be at least ${MIN_PASSWORD_CHARS} characters` + } + + if (password.length > MAX_PASSWORD_CHARS) { + fieldErrors.password = `Password must be at most ${MAX_PASSWORD_CHARS} characters` + } + + if (password !== repeat) { + fieldErrors.repeat = 'Passwords do not match' + } + + if (fieldErrors.password || fieldErrors.repeat) { + return data({ fieldErrors }, { status: 400 }) + } + + const result = await serverFetch(request, `v1/auth/reset-password/confirm/${id}`, { + method: 'POST', + body: { newPassword: password }, + skipAuth: true, + }) + + if (result.error) { + return data({ error: result.error as string }, { status: 400 }) + } + + return redirect('/login?password_updated=true') +} + export default function Index() { return } diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index b838c9079..5c2d428d2 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -1,11 +1,14 @@ import _split from 'lodash/split' -import { type LinksFunction, type MetaFunction } from 'react-router' +import { type ActionFunctionArgs, type LinksFunction, type LoaderFunctionArgs, type MetaFunction } from 'react-router' +import { data } from 'react-router' +import { serverFetch } from '~/api/api.server' import { useRequiredParams } from '~/hooks/useRequiredParams' import { API_URL, MAIN_URL } from '~/lib/constants' import ViewProject from '~/pages/Project/View' import { CurrentProjectProvider } from '~/providers/CurrentProjectProvider' import ProjectViewStyle from '~/styles/ProjectViewStyle.css?url' +import { redirectIfNotAuthenticated, createHeadersWithCookies } from '~/utils/session.server' export const links: LinksFunction = () => [{ rel: 'stylesheet', href: ProjectViewStyle }] @@ -22,6 +25,218 @@ export const meta: MetaFunction = ({ location }) => { ] } +export async function loader({ request }: LoaderFunctionArgs) { + redirectIfNotAuthenticated(request) + return null +} + +export interface ProjectViewActionData { + success?: boolean + intent?: string + error?: string + data?: unknown +} + +export async function action({ request, params }: ActionFunctionArgs) { + redirectIfNotAuthenticated(request) + + const { id: projectId } = params + const formData = await request.formData() + const intent = formData.get('intent')?.toString() + + switch (intent) { + // Goals + case 'create-goal': { + const name = formData.get('name')?.toString() || '' + const goalType = formData.get('goalType')?.toString() || 'custom' + const goalValue = formData.get('goalValue')?.toString() || '' + + const result = await serverFetch(request, 'goal', { + method: 'POST', + body: { pid: projectId, name, goalType, goalValue }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-goal': { + const goalId = formData.get('goalId')?.toString() + const name = formData.get('name')?.toString() + const goalValue = formData.get('goalValue')?.toString() + + const result = await serverFetch(request, `goal/${goalId}`, { + method: 'PUT', + body: { name, goalValue }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-goal': { + const goalId = formData.get('goalId')?.toString() + + const result = await serverFetch(request, `goal/${goalId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Funnels + case 'create-funnel': { + const name = formData.get('name')?.toString() || '' + const steps = JSON.parse(formData.get('steps')?.toString() || '[]') + + const result = await serverFetch(request, 'project/funnel', { + method: 'POST', + body: { pid: projectId, name, steps }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-funnel': { + const funnelId = formData.get('funnelId')?.toString() + const name = formData.get('name')?.toString() || '' + const steps = JSON.parse(formData.get('steps')?.toString() || '[]') + + const result = await serverFetch(request, 'project/funnel', { + method: 'PATCH', + body: { id: funnelId, pid: projectId, name, steps }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-funnel': { + const funnelId = formData.get('funnelId')?.toString() + + const result = await serverFetch(request, `project/funnel/${funnelId}/${projectId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Alerts + case 'create-alert': { + const name = formData.get('name')?.toString() || '' + const queryMetric = formData.get('queryMetric')?.toString() || 'page_views' + const queryCondition = formData.get('queryCondition')?.toString() || 'greater_than' + const queryValue = Number(formData.get('queryValue')?.toString() || '0') + const queryTime = Number(formData.get('queryTime')?.toString() || '1') + + const result = await serverFetch(request, 'alert', { + method: 'POST', + body: { pid: projectId, name, queryMetric, queryCondition, queryValue, queryTime, active: true }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-alert': { + const alertId = formData.get('alertId')?.toString() + const name = formData.get('name')?.toString() + const active = formData.get('active') === 'true' + const queryMetric = formData.get('queryMetric')?.toString() + const queryCondition = formData.get('queryCondition')?.toString() + const queryValue = formData.get('queryValue') ? Number(formData.get('queryValue')) : undefined + const queryTime = formData.get('queryTime') ? Number(formData.get('queryTime')) : undefined + + const body: Record = {} + if (name !== undefined) body.name = name + body.active = active + if (queryMetric !== undefined) body.queryMetric = queryMetric + if (queryCondition !== undefined) body.queryCondition = queryCondition + if (queryValue !== undefined) body.queryValue = queryValue + if (queryTime !== undefined) body.queryTime = queryTime + + const result = await serverFetch(request, `alert/${alertId}`, { + method: 'PUT', + body, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-alert': { + const alertId = formData.get('alertId')?.toString() + + const result = await serverFetch(request, `alert/${alertId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + default: + return data({ error: 'Unknown action' }, { status: 400 }) + } +} + export default function Index() { const { id } = useRequiredParams<{ id: string }>() diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index add709021..388ab40b6 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -1,11 +1,220 @@ -import type { LoaderFunctionArgs } from 'react-router' +import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router' +import { data } from 'react-router' +import { serverFetch } from '~/api/api.server' +import { Project } from '~/lib/models/Project' import ProjectSettings from '~/pages/Project/Settings' +import { redirectIfNotAuthenticated, createHeadersWithCookies } from '~/utils/session.server' export async function loader({ request }: LoaderFunctionArgs) { + redirectIfNotAuthenticated(request) return { requestOrigin: request.headers.get('origin') } } +export interface ProjectSettingsActionData { + success?: boolean + intent?: string + error?: string + fieldErrors?: { + name?: string + origins?: string + ipBlacklist?: string + password?: string + transferEmail?: string + } + project?: Project +} + +export async function action({ request, params }: ActionFunctionArgs) { + redirectIfNotAuthenticated(request) + + const { id } = params + const formData = await request.formData() + const intent = formData.get('intent')?.toString() + + switch (intent) { + case 'update-project': { + const name = formData.get('name')?.toString() + const isPublic = formData.get('public') === 'true' + const isPasswordProtected = formData.get('isPasswordProtected') === 'true' + const password = formData.get('password')?.toString() + const origins = formData.get('origins')?.toString() + const ipBlacklist = formData.get('ipBlacklist')?.toString() + const botsProtectionLevel = formData.get('botsProtectionLevel')?.toString() + const countryBlacklist = formData.get('countryBlacklist')?.toString() + const websiteUrl = formData.get('websiteUrl')?.toString() + const captchaDifficulty = formData.get('captchaDifficulty')?.toString() + + const fieldErrors: ProjectSettingsActionData['fieldErrors'] = {} + + if (name && name.length > 50) { + fieldErrors.name = 'Project name must be 50 characters or less' + } + + if (fieldErrors.name) { + return data({ intent, fieldErrors }, { status: 400 }) + } + + const updateData: Record = {} + if (name !== undefined) updateData.name = name + updateData.public = isPublic + updateData.isPasswordProtected = isPasswordProtected + if (password !== undefined) updateData.password = password + if (origins !== undefined) updateData.origins = origins ? origins.split(',').map((o) => o.trim()) : null + if (ipBlacklist !== undefined) + updateData.ipBlacklist = ipBlacklist ? ipBlacklist.split(',').map((ip) => ip.trim()) : null + if (botsProtectionLevel !== undefined) updateData.botsProtectionLevel = botsProtectionLevel + if (countryBlacklist !== undefined) + updateData.countryBlacklist = countryBlacklist ? JSON.parse(countryBlacklist) : [] + if (websiteUrl !== undefined) updateData.websiteUrl = websiteUrl || null + if (captchaDifficulty !== undefined) updateData.captchaDifficulty = Number(captchaDifficulty) + + const result = await serverFetch(request, `project/${id}`, { + method: 'PUT', + body: updateData, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, project: result.data as Project }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-project': { + const result = await serverFetch(request, `project/${id}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'reset-project': { + const result = await serverFetch(request, `project/reset/${id}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-partially': { + const from = formData.get('from')?.toString() + const to = formData.get('to')?.toString() + + const result = await serverFetch(request, `project/partially/${id}`, { + method: 'DELETE', + body: { from, to }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'reset-filters': { + const type = formData.get('type')?.toString() + const value = formData.get('value')?.toString() + + const result = await serverFetch(request, `project/reset-filters/${id}`, { + method: 'DELETE', + body: { type, value }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'transfer-project': { + const email = formData.get('email')?.toString() || '' + + if (!email || !email.includes('@')) { + return data( + { intent, fieldErrors: { transferEmail: 'Please enter a valid email address' } }, + { status: 400 }, + ) + } + + const result = await serverFetch(request, 'project/transfer', { + method: 'POST', + body: { projectId: id, email }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'assign-organisation': { + const organisationId = formData.get('organisationId')?.toString() + + const result = await serverFetch(request, `project/${id}/organisation`, { + method: 'PATCH', + body: { organisationId: organisationId || null }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'regenerate-captcha-key': { + const result = await serverFetch<{ captchaSecretKey: string }>(request, `project/secret-gen/${id}`, { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, project: { captchaSecretKey: result.data?.captchaSecretKey } as Project }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + default: + return data({ error: 'Unknown action' }, { status: 400 }) + } +} + export default function Index() { return } diff --git a/web/app/routes/recovery.tsx b/web/app/routes/recovery.tsx index ee80df904..e57b311c8 100644 --- a/web/app/routes/recovery.tsx +++ b/web/app/routes/recovery.tsx @@ -1,7 +1,10 @@ -import type { HeadersFunction } from 'react-router' +import type { ActionFunctionArgs, HeadersFunction } from 'react-router' +import { data, redirect } from 'react-router' import { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' import ForgotPassword from '~/pages/Auth/ForgotPassword' +import { isValidEmail } from '~/utils/validator' export const sitemap: SitemapFunction = () => ({ exclude: true, @@ -12,6 +15,38 @@ export const headers: HeadersFunction = ({ parentHeaders }) => { return parentHeaders } +export interface ForgotPasswordActionData { + success?: boolean + error?: string + fieldErrors?: { + email?: string + } +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData() + const email = formData.get('email')?.toString() || '' + + if (!isValidEmail(email)) { + return data( + { fieldErrors: { email: 'Please enter a valid email address' } }, + { status: 400 }, + ) + } + + const result = await serverFetch(request, 'v1/auth/reset-password', { + method: 'POST', + body: { email }, + skipAuth: true, + }) + + if (result.error) { + return data({ error: result.error as string }, { status: 400 }) + } + + return redirect('/?password_reset_sent=true') +} + export default function Index() { return } diff --git a/web/app/routes/user-settings.tsx b/web/app/routes/user-settings.tsx index 732ff9a6a..27dce54b5 100644 --- a/web/app/routes/user-settings.tsx +++ b/web/app/routes/user-settings.tsx @@ -1,11 +1,262 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router' +import { data } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' +import { User } from '~/lib/models/User' import UserSettings from '~/pages/UserSettings' +import { redirectIfNotAuthenticated, createHeadersWithCookies, createAuthCookies } from '~/utils/session.server' +import { isValidEmail, isValidPassword, MIN_PASSWORD_CHARS, MAX_PASSWORD_CHARS } from '~/utils/validator' export const sitemap: SitemapFunction = () => ({ exclude: true, }) +export async function loader({ request }: LoaderFunctionArgs) { + redirectIfNotAuthenticated(request) + return null +} + +export interface UserSettingsActionData { + success?: boolean + intent?: string + error?: string + fieldErrors?: { + email?: string + password?: string + repeat?: string + } + user?: Partial + apiKey?: string + twoFAData?: { + secret?: string + otpauthUrl?: string + twoFactorRecoveryCode?: string + } +} + +export async function action({ request }: ActionFunctionArgs) { + redirectIfNotAuthenticated(request) + + const formData = await request.formData() + const intent = formData.get('intent')?.toString() + + switch (intent) { + case 'update-profile': { + const email = formData.get('email')?.toString() || '' + const password = formData.get('password')?.toString() || '' + const repeat = formData.get('repeat')?.toString() || '' + const timezone = formData.get('timezone')?.toString() + const timeFormat = formData.get('timeFormat')?.toString() + const reportFrequency = formData.get('reportFrequency')?.toString() + + const fieldErrors: UserSettingsActionData['fieldErrors'] = {} + + if (email && !isValidEmail(email)) { + fieldErrors.email = 'Please enter a valid email address' + } + + if (password) { + if (!isValidPassword(password)) { + fieldErrors.password = `Password must be at least ${MIN_PASSWORD_CHARS} characters` + } + if (password.length > MAX_PASSWORD_CHARS) { + fieldErrors.password = `Password must be at most ${MAX_PASSWORD_CHARS} characters` + } + if (password !== repeat) { + fieldErrors.repeat = 'Passwords do not match' + } + } + + if (fieldErrors.email || fieldErrors.password || fieldErrors.repeat) { + return data({ intent, fieldErrors }, { status: 400 }) + } + + const updateData: Record = {} + if (email) updateData.email = email + if (password) updateData.password = password + if (timezone) updateData.timezone = timezone + if (timeFormat) updateData.timeFormat = timeFormat + if (reportFrequency) updateData.reportFrequency = reportFrequency + + const result = await serverFetch(request, 'user', { + method: 'PUT', + body: updateData, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, user: result.data as User }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'generate-api-key': { + const result = await serverFetch<{ apiKey: string }>(request, 'user/api-key', { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, apiKey: result.data?.apiKey }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-api-key': { + const result = await serverFetch(request, 'user/api-key', { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'toggle-live-visitors': { + const show = formData.get('show') === 'true' + + const result = await serverFetch>(request, 'user/live-visitors', { + method: 'PUT', + body: { show }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, user: result.data as Partial }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'toggle-login-notifications': { + const receiveLoginNotifications = formData.get('receiveLoginNotifications') === 'true' + + const result = await serverFetch(request, 'user/recieve-login-notifications', { + method: 'POST', + body: { receiveLoginNotifications }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'confirm-email': { + const result = await serverFetch(request, 'user/confirm_email', { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-account': { + const feedback = formData.get('feedback')?.toString() || '' + + const result = await serverFetch(request, 'user', { + method: 'DELETE', + body: { feedback }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'generate-2fa': { + const result = await serverFetch<{ secret: string; otpauthUrl: string }>(request, '2fa/generate', { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, twoFAData: result.data as { secret: string; otpauthUrl: string } }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'enable-2fa': { + const twoFactorAuthenticationCode = formData.get('code')?.toString() || '' + + const result = await serverFetch<{ twoFactorRecoveryCode: string; accessToken: string; refreshToken: string }>( + request, + '2fa/enable', + { + method: 'POST', + body: { twoFactorAuthenticationCode }, + }, + ) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + // Set new auth cookies from the response + const { twoFactorRecoveryCode, accessToken, refreshToken } = result.data! + const cookies = createAuthCookies({ accessToken, refreshToken }, true) + + return data( + { intent, success: true, twoFAData: { twoFactorRecoveryCode } }, + { headers: createHeadersWithCookies(cookies) }, + ) + } + + case 'disable-2fa': { + const twoFactorAuthenticationCode = formData.get('code')?.toString() || '' + + const result = await serverFetch(request, '2fa/disable', { + method: 'POST', + body: { twoFactorAuthenticationCode }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + default: + return data({ error: 'Unknown action' }, { status: 400 }) + } +} + export default function UserSettingsPage() { return } diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 31d1be3a7..b252fca6e 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -1458,6 +1458,7 @@ "organisation": "Organisation", "shields": "Shields", "access": "Access settings", + "resetSuccess": "The project has been reset", "gsc": { "connect": "Connect your project to your Google Search Console account to get stats like what keywords people are using to find your website.", "disconnected": "The project has been disconnected from your Google Search Console account.", From 97e4dda6c34c47440968e31380baab81d9a63133 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 4 Jan 2026 03:46:20 +0000 Subject: [PATCH 05/57] fix: auth form revalidation --- web/app/pages/Auth/Signin/Signin.tsx | 27 +++++++++++++++++--- web/app/pages/Auth/Signup/Signup.tsx | 37 +++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/web/app/pages/Auth/Signin/Signin.tsx b/web/app/pages/Auth/Signin/Signin.tsx index 79f5d73fe..8158f6f1f 100644 --- a/web/app/pages/Auth/Signin/Signin.tsx +++ b/web/app/pages/Auth/Signin/Signin.tsx @@ -42,6 +42,8 @@ const Signin = () => { // State for SSO-triggered 2FA (client-side flow) const [sso2FARequired, setSso2FARequired] = useState(false) + const [clearedErrors, setClearedErrors] = useState>(new Set()) + const { setUser, setTotalMonthlyEvents, setIsAuthenticated } = useAuth() const isFormSubmitting = navigation.state === 'submitting' @@ -62,6 +64,23 @@ const Signin = () => { } }, [actionData?.error, actionData?.fieldErrors]) + const clearFieldError = (fieldName: string) => { + if (actionData?.fieldErrors?.[fieldName as keyof typeof actionData.fieldErrors]) { + setClearedErrors((prev) => new Set(prev).add(fieldName)) + } + } + + const getFieldError = (fieldName: string) => { + if (clearedErrors.has(fieldName)) { + return undefined + } + return actionData?.fieldErrors?.[fieldName as keyof typeof actionData.fieldErrors] + } + + const handleFormSubmit = () => { + setClearedErrors(new Set()) + } + const handle2FAInput = (event: React.ChangeEvent) => { const { target: { value }, @@ -304,14 +323,15 @@ const Signin = () => {
- + clearFieldError('email')} /> { } hint={t('auth.common.hint', { amount: MIN_PASSWORD_CHARS })} - error={actionData?.fieldErrors?.password} + error={getFieldError('password')} disabled={isLoading} + onChange={() => clearFieldError('password')} /> {/* Hidden input for form submission since Headless UI Checkbox doesn't submit natively */} diff --git a/web/app/pages/Auth/Signup/Signup.tsx b/web/app/pages/Auth/Signup/Signup.tsx index 2d72702cf..efd1e1de7 100644 --- a/web/app/pages/Auth/Signup/Signup.tsx +++ b/web/app/pages/Auth/Signup/Signup.tsx @@ -42,6 +42,8 @@ const Signup = () => { const [isSsoLoading, setIsSsoLoading] = useState(false) + const [clearedErrors, setClearedErrors] = useState>(new Set()) + const { setUser, setTotalMonthlyEvents, setIsAuthenticated } = useAuth() const isFormSubmitting = navigation.state === 'submitting' @@ -54,6 +56,23 @@ const Signup = () => { } }, [actionData?.error, actionData?.fieldErrors]) + const clearFieldError = (fieldName: string) => { + if (actionData?.fieldErrors?.[fieldName as keyof typeof actionData.fieldErrors]) { + setClearedErrors((prev) => new Set(prev).add(fieldName)) + } + } + + const getFieldError = (fieldName: string) => { + if (clearedErrors.has(fieldName)) { + return undefined + } + return actionData?.fieldErrors?.[fieldName as keyof typeof actionData.fieldErrors] + } + + const handleFormSubmit = () => { + setClearedErrors(new Set()) + } + const onSsoLogin = async (provider: SSOProvider) => { const authWindow = openBrowserWindow('') @@ -193,29 +212,32 @@ const Signup = () => {
- + clearFieldError('email')} /> clearFieldError('password')} /> clearFieldError('repeat')} /> {/* Hidden fields for checkbox values since Headless UI Checkbox doesn't submit natively */} @@ -225,7 +247,10 @@ const Signup = () => { {isSelfhosted ? null : ( { + setTos(checked) + clearFieldError('tos') + }} disabled={isLoading} label={ @@ -255,7 +280,7 @@ const Signup = () => { classes={{ hint: '!text-red-600 dark:!text-red-500', }} - hint={actionData?.fieldErrors?.tos} + hint={getFieldError('tos')} /> )} From 844b3305cc1ee0b428fc5fa7b00e20228efc580b Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 4 Jan 2026 04:41:16 +0000 Subject: [PATCH 06/57] Move more routes to RR loaders / actions --- web/app/api/index.ts | 72 +-------- web/app/pages/Auth/ChangeEmail/index.tsx | 62 -------- web/app/pages/Auth/VerifyEmail/index.tsx | 67 -------- .../pages/Organisations/Settings/index.tsx | 4 +- web/app/pages/Project/ConfirmShare/index.tsx | 65 -------- .../Emails/ConfirmReportsShare/index.tsx | 65 -------- .../TransferProjectConfirm.tsx | 63 -------- .../TransferProject/TransferProjectReject.tsx | 63 -------- web/app/pages/Unsubscribe/index.tsx | 61 ------- web/app/providers/AuthProvider.tsx | 10 ++ .../routes/3rd-party-unsubscribe.$token.tsx | 56 ++++++- web/app/routes/change-email.$id.tsx | 63 ++++++-- web/app/routes/gsc-connected.tsx | 149 ++++++++++++------ web/app/routes/organisation.invite.$id.tsx | 79 +++++----- web/app/routes/project.transfer.cancel.tsx | 56 ++++++- web/app/routes/project.transfer.confirm.tsx | 54 ++++++- .../projects.$id.subscribers.invite.tsx | 63 +++++++- web/app/routes/reports-unsubscribe.$token.tsx | 56 ++++++- web/app/routes/share.$id.tsx | 53 ++++++- web/app/routes/verify.$id.tsx | 56 ++++++- web/app/utils/session.server.ts | 7 +- 21 files changed, 584 insertions(+), 640 deletions(-) delete mode 100644 web/app/pages/Auth/ChangeEmail/index.tsx delete mode 100644 web/app/pages/Auth/VerifyEmail/index.tsx delete mode 100644 web/app/pages/Project/ConfirmShare/index.tsx delete mode 100644 web/app/pages/Project/Settings/Emails/ConfirmReportsShare/index.tsx delete mode 100644 web/app/pages/Project/Settings/TransferProject/TransferProjectConfirm.tsx delete mode 100644 web/app/pages/Project/Settings/TransferProject/TransferProjectReject.tsx delete mode 100644 web/app/pages/Unsubscribe/index.tsx diff --git a/web/app/api/index.ts b/web/app/api/index.ts index e61abfcc9..aac265e4b 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -84,30 +84,6 @@ export const authMe = (config?: AxiosRequestConfig) => } => response.data, ) -export const verifyEmail = ({ id }: { id: string }) => - api - .get(`v1/auth/verify-email/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const confirmChangeEmail = ({ id }: { id: string }) => - api - .get(`v1/auth/change-email/confirm/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const verifyShare = ({ path, id }: { path: string; id: string }) => - api - .get(`/project/${path}/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProjects = (take = 0, skip = 0, search = '', period = '7d', sort = 'alpha_asc') => api .get(`/project?take=${take}&skip=${skip}&search=${search}&period=${period}&sort=${sort}`) @@ -854,6 +830,14 @@ export const rejectProjectShare = (actionId: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) +export const transferProject = (projectId: string, email: string) => + api + .post('/project/transfer', { projectId, email }) + .then((response) => response.data) + .catch((error) => { + throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message + }) + export const acceptProjectShare = (actionId: string) => api .get(`/user/share/${actionId}`) @@ -1512,14 +1496,6 @@ export const removeSubscriber = (id: string, subscriberId: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const confirmSubscriberInvite = (id: string, token: string) => - api - .get(`project/${id}/subscribers/invite?token=${token}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProjectDataCustomEvents = ( pid: string, tb = 'hour', @@ -1547,22 +1523,6 @@ export const getProjectDataCustomEvents = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const rejectTransferProject = (uuid: string) => - api - .delete(`project/transfer?token=${uuid}`) - .then((response): unknown => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const confirmTransferProject = (uuid: string) => - api - .get(`project/transfer?token=${uuid}`) - .then((response): unknown => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const generateSSOAuthURL = async (provider: SSOProvider, redirectUrl?: string) => { const payload = { provider, @@ -1787,22 +1747,6 @@ export const getBlogPostWithCategory = (category: string, slug: string) => throw error }) -export const unsubscribeFromEmailReports = (token: string) => - api - .get(`user/unsubscribe/${token}`) - .then((response) => response.data) - .catch((error) => { - throw error - }) - -export const unsubscribeFromEmailReports3rdParty = (token: string) => - api - .get(`project/unsubscribe/${token}`) - .then((response) => response.data) - .catch((error) => { - throw error - }) - export const updateErrorStatus = (pid: string, status: 'resolved' | 'active', eid?: string, eids?: string[]) => api .patch('log/error-status', { pid, eid, eids, status }) diff --git a/web/app/pages/Auth/ChangeEmail/index.tsx b/web/app/pages/Auth/ChangeEmail/index.tsx deleted file mode 100644 index 8f95121ac..000000000 --- a/web/app/pages/Auth/ChangeEmail/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import _isString from 'lodash/isString' -import { useState, useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { useParams } from 'react-router' - -import { confirmChangeEmail } from '~/api' -import StatusPage from '~/ui/StatusPage' -import routes from '~/utils/routes' - -const ChangeEmail = () => { - const { t } = useTranslation('common') - const { id } = useParams() - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- Async email verification flow - setLoading(true) - - if (!_isString(id)) { - setError(t('auth.verification.invalid')) - setLoading(false) - return - } - - confirmChangeEmail({ id }) - .then(() => { - setLoading(false) - }) - .catch((reason) => { - setError(reason.toString()) - setLoading(false) - }) - }, [id]) // eslint-disable-line - - if (loading) { - return - } - - if (error) { - return ( - - ) - } - - return ( - - ) -} - -export default ChangeEmail diff --git a/web/app/pages/Auth/VerifyEmail/index.tsx b/web/app/pages/Auth/VerifyEmail/index.tsx deleted file mode 100644 index bca7b5dd3..000000000 --- a/web/app/pages/Auth/VerifyEmail/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import _isString from 'lodash/isString' -import { useState, useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { useParams } from 'react-router' - -import { verifyEmail } from '~/api' -import { useAuth } from '~/providers/AuthProvider' -import StatusPage from '~/ui/StatusPage' -import routes from '~/utils/routes' - -const VerifyEmail = () => { - const { t } = useTranslation('common') - const { id } = useParams() - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const { mergeUser } = useAuth() - - useEffect(() => { - setLoading(true) - - if (!_isString(id)) { - setError(t('auth.verification.invalid')) - setLoading(false) - return - } - - const verify = async () => { - try { - await verifyEmail({ id }) - mergeUser({ isActive: true }) - } catch (reason: any) { - setError(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } finally { - setLoading(false) - } - } - - verify() - }, [id]) // eslint-disable-line - - if (loading) { - return - } - - if (error) { - return ( - - ) - } - - return ( - - ) -} - -export default VerifyEmail diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index c35967852..cca63bf48 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -7,12 +7,12 @@ import { useTranslation } from 'react-i18next' import { Link, useNavigate, useFetcher } from 'react-router' import { toast } from 'sonner' -import { getOrganisation } from '~/api' +import { getOrganisation, transferProject } from '~/api' import { useRequiredParams } from '~/hooks/useRequiredParams' import { TITLE_SUFFIX } from '~/lib/constants' -import type { OrganisationSettingsActionData } from '~/routes/organisations.$id' import { DetailedOrganisation } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' +import type { OrganisationSettingsActionData } from '~/routes/organisations.$id' import Button from '~/ui/Button' import Input from '~/ui/Input' import Loader from '~/ui/Loader' diff --git a/web/app/pages/Project/ConfirmShare/index.tsx b/web/app/pages/Project/ConfirmShare/index.tsx deleted file mode 100644 index 26e411933..000000000 --- a/web/app/pages/Project/ConfirmShare/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import _split from 'lodash/split' -import { useState, useEffect } from 'react' -import { useTranslation } from 'react-i18next' -import { useParams } from 'react-router' - -import { verifyShare } from '~/api' -import StatusPage from '~/ui/StatusPage' -import routes from '~/utils/routes' - -const ConfirmShare = () => { - const { t } = useTranslation('common') - const { id } = useParams() - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - useEffect(() => { - setLoading(true) - const path = _split(window.location.pathname, '/')[1] - - if (!id) { - setError(t('common.error')) - setLoading(false) - return - } - - const verify = async () => { - try { - await verifyShare({ path, id }) - } catch (reason: any) { - setError(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } finally { - setLoading(false) - } - } - - verify() - }, [id]) // eslint-disable-line - - if (loading) { - return - } - - if (error) { - return ( - - ) - } - - return ( - - ) -} - -export default ConfirmShare diff --git a/web/app/pages/Project/Settings/Emails/ConfirmReportsShare/index.tsx b/web/app/pages/Project/Settings/Emails/ConfirmReportsShare/index.tsx deleted file mode 100644 index af7c7aea7..000000000 --- a/web/app/pages/Project/Settings/Emails/ConfirmReportsShare/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState, useEffect, memo } from 'react' -import { useTranslation } from 'react-i18next' -import { useParams } from 'react-router' - -import { confirmSubscriberInvite } from '~/api' -import StatusPage from '~/ui/StatusPage' -import routes from '~/utils/routes' - -const ConfirmReportsShare = () => { - const { t } = useTranslation('common') - const { id } = useParams() - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - const handleConfirm = async (token: string) => { - try { - await confirmSubscriberInvite(id as string, token) - } catch { - setError(t('apiNotifications.invalidToken')) - } finally { - setLoading(false) - } - } - - useEffect(() => { - setLoading(true) - const query = new URLSearchParams(window.location.search) - const token = query.get('token') - - if (!token) { - setError(t('apiNotifications.invalidToken')) - setLoading(false) - return - } - - handleConfirm(token) - }, []) // eslint-disable-line - - if (loading) { - return - } - - if (error) { - return ( - - ) - } - - return ( - - ) -} - -export default memo(ConfirmReportsShare) diff --git a/web/app/pages/Project/Settings/TransferProject/TransferProjectConfirm.tsx b/web/app/pages/Project/Settings/TransferProject/TransferProjectConfirm.tsx deleted file mode 100644 index 7f555cf87..000000000 --- a/web/app/pages/Project/Settings/TransferProject/TransferProjectConfirm.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useEffect } from 'react' -import { useTranslation } from 'react-i18next' - -import { confirmTransferProject } from '~/api' -import StatusPage from '~/ui/StatusPage' -import routes from '~/utils/routes' - -const TransferProjectConfirm = () => { - const { t } = useTranslation('common') - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - const handleConfirm = async (token: string) => { - try { - await confirmTransferProject(token) - } catch { - setError(t('apiNotifications.invalidToken')) - } finally { - setLoading(false) - } - } - - useEffect(() => { - setLoading(true) - const query = new URLSearchParams(window.location.search) - const token = query.get('token') - - if (!token) { - setError(t('apiNotifications.invalidToken')) - setLoading(false) - return - } - - handleConfirm(token) - }, []) // eslint-disable-line - - if (loading) { - return - } - - if (error) { - return ( - - ) - } - - return ( - - ) -} - -export default TransferProjectConfirm diff --git a/web/app/pages/Project/Settings/TransferProject/TransferProjectReject.tsx b/web/app/pages/Project/Settings/TransferProject/TransferProjectReject.tsx deleted file mode 100644 index 34b3f2219..000000000 --- a/web/app/pages/Project/Settings/TransferProject/TransferProjectReject.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useEffect } from 'react' -import { useTranslation } from 'react-i18next' - -import { rejectTransferProject } from '~/api' -import StatusPage from '~/ui/StatusPage' -import routes from '~/utils/routes' - -const TransferProjectReject = () => { - const { t } = useTranslation('common') - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - const handleConfirm = async (token: string) => { - try { - await rejectTransferProject(token) - } catch { - setError(t('apiNotifications.invalidToken')) - } finally { - setLoading(false) - } - } - - useEffect(() => { - setLoading(true) - const query = new URLSearchParams(window.location.search) - const token = query.get('token') - - if (!token) { - setError(t('apiNotifications.invalidToken')) - setLoading(false) - return - } - - handleConfirm(token) - }, []) // eslint-disable-line - - if (loading) { - return - } - - if (error) { - return ( - - ) - } - - return ( - - ) -} - -export default TransferProjectReject diff --git a/web/app/pages/Unsubscribe/index.tsx b/web/app/pages/Unsubscribe/index.tsx deleted file mode 100644 index 42b715a26..000000000 --- a/web/app/pages/Unsubscribe/index.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import _isString from 'lodash/isString' -import { useState, useEffect, memo } from 'react' -import { useTranslation } from 'react-i18next' -import { useParams } from 'react-router' - -import { unsubscribeFromEmailReports, unsubscribeFromEmailReports3rdParty } from '~/api' -import { useAuth } from '~/providers/AuthProvider' -import StatusPage from '~/ui/StatusPage' -import routes from '~/utils/routes' - -interface UnsubscribeProps { - type: 'user-reports' | '3rdparty' -} - -const Unsubscribe = ({ type }: UnsubscribeProps) => { - const { t } = useTranslation('common') - const { token } = useParams() - const { isAuthenticated } = useAuth() - - const isValidToken = _isString(token) - const [loading, setLoading] = useState(isValidToken) - const [error, setError] = useState(isValidToken ? '' : t('apiNotifications.invalidToken')) - - useEffect(() => { - if (!isValidToken) { - return - } - - const apiCall = type === 'user-reports' ? unsubscribeFromEmailReports : unsubscribeFromEmailReports3rdParty - - apiCall(token) - .catch((reason) => { - setError(_isString(reason) ? reason : reason?.response?.data?.message || reason.message) - }) - .finally(() => { - setLoading(false) - }) - }, [token, type, isValidToken]) - - const primaryAction = isAuthenticated - ? { label: t('common.dashboard'), to: routes.dashboard, primary: true } - : { label: t('auth.signin.button'), to: routes.signin, primary: true } - - if (loading) { - return - } - - if (error) { - return ( - - ) - } - - return -} - -export default memo(Unsubscribe) diff --git a/web/app/providers/AuthProvider.tsx b/web/app/providers/AuthProvider.tsx index b1dcbec56..32525f0f0 100644 --- a/web/app/providers/AuthProvider.tsx +++ b/web/app/providers/AuthProvider.tsx @@ -46,6 +46,16 @@ export const AuthProvider = ({ // TODO: @deprecated const [totalMonthlyEvents, setTotalMonthlyEvents] = useState(initialTotalMonthlyEvents || 0) + // Sync state with props when they change (e.g., after login redirect) + useEffect(() => { + if (initialUser) { + setUser(initialUser) + setIsAuthenticated(true) + setIsLoading(false) + setTotalMonthlyEvents(initialTotalMonthlyEvents || 0) + } + }, [initialUser, initialTotalMonthlyEvents]) + const logout = useCallback((invalidateAllSessions?: boolean) => { setIsAuthenticated(false) setUser(null) diff --git a/web/app/routes/3rd-party-unsubscribe.$token.tsx b/web/app/routes/3rd-party-unsubscribe.$token.tsx index 4847321dc..cd98e7f6a 100644 --- a/web/app/routes/3rd-party-unsubscribe.$token.tsx +++ b/web/app/routes/3rd-party-unsubscribe.$token.tsx @@ -1,21 +1,65 @@ -import { redirect } from 'react-router' +import type { LoaderFunctionArgs } from 'react-router' +import { redirect, useLoaderData } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' -import Unsubscribe from '~/pages/Unsubscribe' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export async function loader() { +export interface UnsubscribeLoaderData { + success: boolean + error?: string +} + +export async function loader({ request, params }: LoaderFunctionArgs): Promise { if (isSelfhosted) { return redirect('/login', 302) } - return null + const { token } = params + + if (!token) { + return { success: false, error: 'Invalid token' } + } + + const result = await serverFetch(request, `project/unsubscribe/${token}`, { + skipAuth: true, + }) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Failed to unsubscribe' + return { success: false, error: errorMessage } + } + + return { success: true } } -export default function Index() { - return +export default function ThirdPartyUnsubscribeRoute() { + const data = useLoaderData() + + if (data.error) { + return ( + + ) + } + + return ( + + ) } diff --git a/web/app/routes/change-email.$id.tsx b/web/app/routes/change-email.$id.tsx index f586a2336..ff4c12181 100644 --- a/web/app/routes/change-email.$id.tsx +++ b/web/app/routes/change-email.$id.tsx @@ -1,27 +1,70 @@ -import type { HeadersFunction } from 'react-router' -import { redirect } from 'react-router' +import type { HeadersFunction, LoaderFunctionArgs } from 'react-router' +import { redirect, useLoaderData } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' -import ChangeEmail from '~/pages/Auth/ChangeEmail' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' export const headers: HeadersFunction = ({ parentHeaders }) => { parentHeaders.set('X-Frame-Options', 'DENY') return parentHeaders } -export async function loader() { +export const sitemap: SitemapFunction = () => ({ + exclude: true, +}) + +export interface ChangeEmailLoaderData { + success: boolean + error?: string +} + +export async function loader({ request, params }: LoaderFunctionArgs): Promise { if (isSelfhosted) { return redirect('/login', 302) } - return null + const { id } = params + + if (!id) { + return { success: false, error: 'Invalid verification link' } + } + + const result = await serverFetch(request, `v1/auth/change-email/confirm/${id}`, { + skipAuth: true, + }) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Verification failed' + return { success: false, error: errorMessage } + } + + return { success: true } } -export const sitemap: SitemapFunction = () => ({ - exclude: true, -}) +export default function ChangeEmailRoute() { + const data = useLoaderData() + + if (data.error) { + return ( + + ) + } -export default function Index() { - return + return ( + + ) } diff --git a/web/app/routes/gsc-connected.tsx b/web/app/routes/gsc-connected.tsx index 65ebd3074..962517133 100644 --- a/web/app/routes/gsc-connected.tsx +++ b/web/app/routes/gsc-connected.tsx @@ -1,86 +1,141 @@ import _replace from 'lodash/replace' import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { redirect, useNavigate } from 'react-router' +import type { LoaderFunctionArgs } from 'react-router' +import { redirect, useLoaderData, useNavigate } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' import { processGSCToken } from '~/api' +import { serverFetch } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' import StatusPage from '~/ui/StatusPage' import routes from '~/utils/routes' -export async function loader() { +export const sitemap: SitemapFunction = () => ({ + exclude: true, +}) + +export interface GscConnectedLoaderData { + error?: string + useClientFallback?: boolean +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { if (isSelfhosted) { return redirect('/login', 302) } - return null + const url = new URL(request.url) + + // Google sometimes redirects with hash instead of query params + // The hash won't be available server-side, so we need client-side fallback + const state = url.searchParams.get('state') + const code = url.searchParams.get('code') + + if (!state || !code) { + // If params are missing, it might be in the hash - need client-side handling + return { useClientFallback: true } + } + + const result = await serverFetch<{ pid: string }>(request, 'v1/project/gsc/process-token', { + method: 'POST', + body: { code, state }, + }) + + // If auth fails (401/403), fall back to client-side which has localStorage tokens + if (result.status === 401 || result.status === 403) { + return { useClientFallback: true } + } + + if (result.error || !result.data) { + // For other errors, also try client-side as a fallback + // This handles cases where server-side cookies aren't synced with localStorage + return { useClientFallback: true } + } + + const { pid } = result.data + const redirectUrl = _replace(routes.project_settings, ':id', pid) + + return redirect(`${redirectUrl}?tab=integrations`) } -export const sitemap: SitemapFunction = () => ({ - exclude: true, -}) +export default function GscConnectedRoute() { + const data = useLoaderData() -export default function GscConnected() { - const { t } = useTranslation('common') + // Always use client-side handler when loader indicates fallback + if (data.useClientFallback) { + return + } + + // This shouldn't be reached normally, but handle edge cases + return ( + + ) +} + +function GscHashHandler() { const navigate = useNavigate() - const [loading, setLoading] = useState(true) - const [isError, setIsError] = useState(false) + const [error, setError] = useState(null) + const [{ state, code }] = useState(() => { + if (typeof window === 'undefined') return { state: null, code: null } + // Fix Google's hash URL redirect (sometimes Google uses # instead of ?) + const _location = _replace(window.location.href, `${routes.gsc_connected}#`, `${routes.gsc_connected}?`) + const { searchParams } = new URL(_location) + return { + state: searchParams.get('state'), + code: searchParams.get('code'), + } + }) useEffect(() => { - // For some reason, Google redirects to a hash URL, let's fix it - const _location = _replace(window.location.href, `${routes.gsc_connected}#`, `${routes.gsc_connected}?`) + if (!state || !code) return - const { searchParams } = new URL(_location) - const state = searchParams.get('state') - const code = searchParams.get('code') - - // Prevent duplicate callback processing in React 18 StrictMode (dev) or other double-invocation scenarios - const processedKey = state ? `gsc_processed:${state}` : null - if (processedKey && sessionStorage.getItem(processedKey) === '1') { - // eslint-disable-next-line react-hooks/set-state-in-effect -- Checking sessionStorage for duplicate processing - setLoading(false) + // Prevent duplicate processing in React StrictMode or on refresh + const processedKey = `gsc_processed:${state}` + if (sessionStorage.getItem(processedKey) === '1') { return } + sessionStorage.setItem(processedKey, '1') - const processCode = async () => { - if (!state || !code) { - setIsError(true) - return - } - - try { - const { pid } = await processGSCToken(code, state) + processGSCToken(code, state) + .then(({ pid }) => { navigate({ pathname: _replace(routes.project_settings, ':id', pid), search: new URLSearchParams({ tab: 'integrations' }).toString(), }) - } catch (reason) { - setIsError(true) - setLoading(false) + }) + .catch((reason) => { + sessionStorage.removeItem(processedKey) + setError(String(reason)) console.error(`[ERROR] Error while processing GSC integration: ${reason}`) - } - } - - setLoading(true) + }) + }, [navigate, state, code]) - processCode() - }, [navigate]) - - if (loading) { - return + if (!state || !code) { + return ( + + ) } - if (isError) { + if (error) { return ( ) } - return null + return } diff --git a/web/app/routes/organisation.invite.$id.tsx b/web/app/routes/organisation.invite.$id.tsx index 0dff39cbb..677792879 100644 --- a/web/app/routes/organisation.invite.$id.tsx +++ b/web/app/routes/organisation.invite.$id.tsx @@ -1,51 +1,50 @@ -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useParams } from 'react-router' +import type { LoaderFunctionArgs } from 'react-router' +import { useLoaderData } from 'react-router' +import type { SitemapFunction } from 'remix-sitemap' -import { acceptOrganisationInvitation } from '~/api' +import { serverFetch } from '~/api/api.server' import StatusPage from '~/ui/StatusPage' import routes from '~/utils/routes' -export default function ConfirmOrganisationInvitation() { - const { t } = useTranslation('common') - const { id } = useParams() - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - setLoading(true) - - if (!id) { - setError(t('common.error')) - setLoading(false) - return - } - - const acceptInvitation = async () => { - try { - await acceptOrganisationInvitation(id) - } catch (reason) { - setError(typeof reason === 'string' ? reason : t('apiNotifications.acceptOrganisationInvitationError')) - } finally { - setLoading(false) - } - } - - acceptInvitation() - }, [id]) // eslint-disable-line - - if (loading) { - return +export const sitemap: SitemapFunction = () => ({ + exclude: true, +}) + +export interface OrganisationInviteLoaderData { + success: boolean + error?: string +} + +export async function loader({ request, params }: LoaderFunctionArgs): Promise { + const { id } = params + + if (!id) { + return { success: false, error: 'Invalid invitation link' } + } + + const result = await serverFetch(request, `/user/organisation/${id}`, { + method: 'POST', + }) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Failed to accept invitation' + return { success: false, error: errorMessage } } - if (error) { + return { success: true } +} + +export default function OrganisationInviteRoute() { + const data = useLoaderData() + + if (data.error) { return ( ) @@ -54,8 +53,8 @@ export default function ConfirmOrganisationInvitation() { return ( ) } diff --git a/web/app/routes/project.transfer.cancel.tsx b/web/app/routes/project.transfer.cancel.tsx index 5ee3cae60..4f8f93d2b 100644 --- a/web/app/routes/project.transfer.cancel.tsx +++ b/web/app/routes/project.transfer.cancel.tsx @@ -1,11 +1,61 @@ +import type { LoaderFunctionArgs } from 'react-router' +import { useLoaderData } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' -import TransferProjectReject from '~/pages/Project/Settings/TransferProject/TransferProjectReject' +import { serverFetch } from '~/api/api.server' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export default function Index() { - return +export interface TransferCancelLoaderData { + success: boolean + error?: string +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { + const url = new URL(request.url) + const token = url.searchParams.get('token') + + if (!token) { + return { success: false, error: 'Invalid or expired token' } + } + + const result = await serverFetch(request, `project/transfer?token=${token}`, { + method: 'DELETE', + }) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Invalid or expired token' + return { success: false, error: errorMessage } + } + + return { success: true } +} + +export default function TransferCancelRoute() { + const data = useLoaderData() + + if (data.error) { + return ( + + ) + } + + return ( + + ) } diff --git a/web/app/routes/project.transfer.confirm.tsx b/web/app/routes/project.transfer.confirm.tsx index 04c7083e1..727a29ba3 100644 --- a/web/app/routes/project.transfer.confirm.tsx +++ b/web/app/routes/project.transfer.confirm.tsx @@ -1,11 +1,59 @@ +import type { LoaderFunctionArgs } from 'react-router' +import { useLoaderData } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' -import TransferProjectConfirm from '~/pages/Project/Settings/TransferProject/TransferProjectConfirm' +import { serverFetch } from '~/api/api.server' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export default function Index() { - return +export interface TransferConfirmLoaderData { + success: boolean + error?: string +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { + const url = new URL(request.url) + const token = url.searchParams.get('token') + + if (!token) { + return { success: false, error: 'Invalid or expired token' } + } + + const result = await serverFetch(request, `project/transfer?token=${token}`) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Invalid or expired token' + return { success: false, error: errorMessage } + } + + return { success: true } +} + +export default function TransferConfirmRoute() { + const data = useLoaderData() + + if (data.error) { + return ( + + ) + } + + return ( + + ) } diff --git a/web/app/routes/projects.$id.subscribers.invite.tsx b/web/app/routes/projects.$id.subscribers.invite.tsx index 54f60285c..608c88dec 100644 --- a/web/app/routes/projects.$id.subscribers.invite.tsx +++ b/web/app/routes/projects.$id.subscribers.invite.tsx @@ -1,5 +1,62 @@ -import ConfirmReportsShare from '~/pages/Project/Settings/Emails/ConfirmReportsShare' +import type { LoaderFunctionArgs } from 'react-router' +import { useLoaderData } from 'react-router' +import type { SitemapFunction } from 'remix-sitemap' -export default function Index() { - return +import { serverFetch } from '~/api/api.server' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' + +export const sitemap: SitemapFunction = () => ({ + exclude: true, +}) + +export interface SubscriberInviteLoaderData { + success: boolean + error?: string +} + +export async function loader({ request, params }: LoaderFunctionArgs): Promise { + const { id } = params + const url = new URL(request.url) + const token = url.searchParams.get('token') + + if (!id || !token) { + return { success: false, error: 'Invalid invitation link' } + } + + const result = await serverFetch(request, `project/${id}/subscribers/invite?token=${token}`, { + skipAuth: true, + }) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Invalid or expired token' + return { success: false, error: errorMessage } + } + + return { success: true } +} + +export default function SubscriberInviteRoute() { + const data = useLoaderData() + + if (data.error) { + return ( + + ) + } + + return ( + + ) } diff --git a/web/app/routes/reports-unsubscribe.$token.tsx b/web/app/routes/reports-unsubscribe.$token.tsx index 82a955903..83c2dec98 100644 --- a/web/app/routes/reports-unsubscribe.$token.tsx +++ b/web/app/routes/reports-unsubscribe.$token.tsx @@ -1,21 +1,65 @@ -import { redirect } from 'react-router' +import type { LoaderFunctionArgs } from 'react-router' +import { redirect, useLoaderData } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' -import Unsubscribe from '~/pages/Unsubscribe' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export async function loader() { +export interface UnsubscribeLoaderData { + success: boolean + error?: string +} + +export async function loader({ request, params }: LoaderFunctionArgs): Promise { if (isSelfhosted) { return redirect('/login', 302) } - return null + const { token } = params + + if (!token) { + return { success: false, error: 'Invalid token' } + } + + const result = await serverFetch(request, `user/unsubscribe/${token}`, { + skipAuth: true, + }) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Failed to unsubscribe' + return { success: false, error: errorMessage } + } + + return { success: true } } -export default function Index() { - return +export default function ReportsUnsubscribeRoute() { + const data = useLoaderData() + + if (data.error) { + return ( + + ) + } + + return ( + + ) } diff --git a/web/app/routes/share.$id.tsx b/web/app/routes/share.$id.tsx index 78eddd3bd..aa523bc3b 100644 --- a/web/app/routes/share.$id.tsx +++ b/web/app/routes/share.$id.tsx @@ -1,11 +1,58 @@ +import type { LoaderFunctionArgs } from 'react-router' +import { useLoaderData } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' -import ConfirmShare from '~/pages/Project/ConfirmShare' +import { serverFetch } from '~/api/api.server' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export default function Index() { - return +export interface ShareLoaderData { + success: boolean + error?: string +} + +export async function loader({ request, params }: LoaderFunctionArgs): Promise { + const { id } = params + + if (!id) { + return { success: false, error: 'Invalid share link' } + } + + const result = await serverFetch(request, `/project/share/${id}`) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Failed to accept invitation' + return { success: false, error: errorMessage } + } + + return { success: true } +} + +export default function ShareRoute() { + const data = useLoaderData() + + if (data.error) { + return ( + + ) + } + + return ( + + ) } diff --git a/web/app/routes/verify.$id.tsx b/web/app/routes/verify.$id.tsx index 91a188e86..e04d2e56a 100644 --- a/web/app/routes/verify.$id.tsx +++ b/web/app/routes/verify.$id.tsx @@ -1,21 +1,65 @@ -import { redirect } from 'react-router' +import type { LoaderFunctionArgs } from 'react-router' +import { redirect, useLoaderData } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { serverFetch } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' -import VerifyEmail from '~/pages/Auth/VerifyEmail' +import StatusPage from '~/ui/StatusPage' +import routes from '~/utils/routes' export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export async function loader() { +export interface VerifyEmailLoaderData { + success: boolean + error?: string +} + +export async function loader({ request, params }: LoaderFunctionArgs): Promise { if (isSelfhosted) { return redirect('/login', 302) } - return null + const { id } = params + + if (!id) { + return { success: false, error: 'Invalid verification link' } + } + + const result = await serverFetch(request, `v1/auth/verify-email/${id}`, { + skipAuth: true, + }) + + if (result.error) { + const errorMessage = typeof result.error === 'string' ? result.error : 'Verification failed' + return { success: false, error: errorMessage } + } + + return { success: true } } -export default function Index() { - return +export default function VerifyEmailRoute() { + const data = useLoaderData() + + if (data.error) { + return ( + + ) + } + + return ( + + ) } diff --git a/web/app/utils/session.server.ts b/web/app/utils/session.server.ts index dd0da8e84..0cefa5f49 100644 --- a/web/app/utils/session.server.ts +++ b/web/app/utils/session.server.ts @@ -35,7 +35,12 @@ function parseCookies(request: Request): Record { for (const cookie of cookieHeader.split(';')) { const [name, ...valueParts] = cookie.trim().split('=') if (name) { - cookies[name] = valueParts.join('=') + const rawValue = valueParts.join('=') + try { + cookies[name] = decodeURIComponent(rawValue) + } catch { + cookies[name] = rawValue + } } } return cookies From 43dbfe779cf73b55dcacb66f26e9e5d5536b3a0a Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 4 Jan 2026 22:50:21 +0000 Subject: [PATCH 07/57] Migrate organisation endpoints to actions / loaders --- web/app/api/index.ts | 57 -------- .../pages/Organisations/OrganisationCard.tsx | 121 ++++++++--------- web/app/pages/Organisations/Organisations.tsx | 116 +++++++--------- .../pages/Organisations/Settings/People.tsx | 126 +++++++++--------- .../pages/Organisations/Settings/Projects.tsx | 93 ++++++------- .../pages/Organisations/Settings/index.tsx | 111 ++++++--------- web/app/routes/organisations.$id.tsx | 73 +++++++++- web/app/routes/organisations._index.tsx | 85 +++++++++++- 8 files changed, 405 insertions(+), 377 deletions(-) diff --git a/web/app/api/index.ts b/web/app/api/index.ts index aac265e4b..770cb901d 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -7,7 +7,6 @@ import { DEFAULT_ALERTS_TAKE, API_URL } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' import { SSOProvider } from '~/lib/models/Auth' import { Metainfo } from '~/lib/models/Metainfo' -import { Role } from '~/lib/models/Organisation' import { Project, Overall, @@ -108,54 +107,6 @@ export const getProjectsAvailableForOrganisation = (take = 0, skip = 0, search?: throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getOrganisations = (take = 0, skip = 0, search?: string) => - api - .get(`/organisation?take=${take}&skip=${skip}&search=${search}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getOrganisation = (id: string) => - api - .get(`/organisation/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const addProjectToOrganisation = (organisationId: string, projectId: string) => - api - .post(`/project/organisation/${organisationId}`, { projectId }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const removeProjectFromOrganisation = (organisationId: string, projectId: string) => - api - .delete(`/project/organisation/${organisationId}/${projectId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const changeOrganisationRole = (memberId: string, role: Role) => - api - .patch(`/organisation/member/${memberId}`, { role }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const inviteOrganisationMember = (organisationId: string, data: { email: string; role: Role }) => - api - .post(`/organisation/${organisationId}/invite`, data) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const acceptOrganisationInvitation = (tokenId: string) => api .post(`/user/organisation/${tokenId}`) @@ -172,14 +123,6 @@ export const rejectOrganisationInvitation = (tokenId: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const removeOrganisationMember = (memberId: string) => - api - .delete(`/organisation/member/${memberId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProject = (pid: string, password?: string) => api .get(`/project/${pid}`, { diff --git a/web/app/pages/Organisations/OrganisationCard.tsx b/web/app/pages/Organisations/OrganisationCard.tsx index b0ce21ca4..eb542fa3d 100644 --- a/web/app/pages/Organisations/OrganisationCard.tsx +++ b/web/app/pages/Organisations/OrganisationCard.tsx @@ -4,14 +4,13 @@ import _map from 'lodash/map' import _replace from 'lodash/replace' import _size from 'lodash/size' import { Settings2Icon } from 'lucide-react' -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router' -import { toast } from 'sonner' +import { Link, useFetcher } from 'react-router' -import { acceptOrganisationInvitation } from '~/api' import { DetailedOrganisation } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' +import type { OrganisationsActionData } from '~/routes/organisations._index' import { Badge, BadgeProps } from '~/ui/Badge' import Modal from '~/ui/Modal' import { Text } from '~/ui/Text' @@ -19,21 +18,36 @@ import routes from '~/utils/routes' interface OrganisationCardProps { organisation: DetailedOrganisation - reloadOrganisations: () => Promise } -export const OrganisationCard = ({ organisation, reloadOrganisations }: OrganisationCardProps) => { +export const OrganisationCard = ({ organisation }: OrganisationCardProps) => { const { t } = useTranslation('common') const { user, mergeUser } = useAuth() const [showInviteModal, setShowInviteModal] = useState(false) + const fetcher = useFetcher() const { name, members } = organisation + const isAccepting = fetcher.state === 'submitting' && fetcher.formData?.get('intent') === 'accept-invitation' + const membership = useMemo( () => _find(members, (member) => member.user.email === user?.email), [members, user?.email], ) + useEffect(() => { + if (fetcher.data?.success && fetcher.data?.intent === 'accept-invitation') { + mergeUser({ + organisationMemberships: _map(user?.organisationMemberships, (item) => { + if (item.id === membership?.id) { + return { ...item, confirmed: true } + } + return item + }), + }) + } + }, [fetcher.data]) // eslint-disable-line react-hooks/exhaustive-deps + const badges = useMemo(() => { const list: BadgeProps[] = [] @@ -58,30 +72,15 @@ export const OrganisationCard = ({ organisation, reloadOrganisations }: Organisa return list }, [t, membership, members]) - const onAccept = async () => { - try { - if (!membership?.id) { - throw new Error('Membership not found') - } - - await acceptOrganisationInvitation(membership.id) - - await reloadOrganisations() - - mergeUser({ - organisationMemberships: _map(user?.organisationMemberships, (item) => { - if (item.id === membership.id) { - return { ...item, confirmed: true } - } - return item - }), - }) - - toast.success(t('apiNotifications.acceptOrganisationInvitation')) - } catch (reason: any) { - console.error(`[ERROR] Error while accepting organisation invitation: ${reason}`) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.acceptOrganisationInvitationError')) + const onAccept = () => { + if (!membership?.id) { + return } + + const formData = new FormData() + formData.set('intent', 'accept-invitation') + formData.set('membershipId', membership.id) + fetcher.submit(formData, { method: 'post' }) } const onElementClick = (e: React.MouseEvent) => { @@ -93,46 +92,23 @@ export const OrganisationCard = ({ organisation, reloadOrganisations }: Organisa setShowInviteModal(true) } - const CardWrapper = ({ - children, - to, - ...props - }: { - children: React.ReactNode - to?: string - onClick?: (e: React.MouseEvent) => void - className?: string - }) => { - if (membership?.role === 'viewer') { - return
{children}
- } - - return ( - - {children} - - ) - } + const isViewer = membership?.role === 'viewer' + const cardClassName = cx( + 'min-h-[153.1px] overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition-colors dark:border-slate-800/25 dark:bg-slate-800/70', + { + 'cursor-pointer hover:bg-gray-200/70 dark:hover:bg-slate-700/60': !isViewer, + }, + ) - return ( - + const cardContent = ( + <>
{name} - {membership?.role === 'viewer' ? null : ( - // dummy button, since the whole card is clickable + {isViewer ? null : (
) : null} - + + ) + + if (isViewer) { + return ( +
+ {cardContent} +
+ ) + } + + return ( + + {cardContent} + ) } diff --git a/web/app/pages/Organisations/Organisations.tsx b/web/app/pages/Organisations/Organisations.tsx index 31fe597b4..60b897903 100644 --- a/web/app/pages/Organisations/Organisations.tsx +++ b/web/app/pages/Organisations/Organisations.tsx @@ -3,19 +3,17 @@ import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' import _size from 'lodash/size' import { SearchIcon, XIcon } from 'lucide-react' -import React, { useState, useEffect, useCallback, useRef } from 'react' +import React, { useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useFetcher } from 'react-router' +import { useFetcher, useLoaderData, useNavigate } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { getOrganisations } from '~/api' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' import useDebounce from '~/hooks/useDebounce' import { ENTRIES_PER_PAGE_DASHBOARD } from '~/lib/constants' -import { DetailedOrganisation } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' -import type { OrganisationsActionData } from '~/routes/organisations._index' +import type { OrganisationsActionData, OrganisationsLoaderData } from '~/routes/organisations._index' import Input from '~/ui/Input' import Loader from '~/ui/Loader' import Modal from '~/ui/Modal' @@ -30,44 +28,73 @@ import { OrganisationCard } from './OrganisationCard' const Organisations = () => { const { isLoading: authLoading } = useAuth() + const loaderData = useLoaderData() const fetcher = useFetcher() + const navigate = useNavigate() const { t } = useTranslation('common') - const [isSearchActive, setIsSearchActive] = useState(false) + const [isSearchActive, setIsSearchActive] = useState(() => !!loaderData?.search) const searchInputRef = useRef(null) const [showActivateEmailModal, setShowActivateEmailModal] = useState(false) - const [search, setSearch] = useState('') + const [search, setSearch] = useState(loaderData?.search || '') const debouncedSearch = useDebounce(search, 500) - const [organisations, setOrganisations] = useState([]) - const [paginationTotal, setPaginationTotal] = useState(0) - const [page, setPage] = useState(1) const [newOrganisationModalOpen, setNewOrganisationModalOpen] = useState(false) const [newOrganisationName, setNewOrganisationName] = useState('') - - const [isLoading, setIsLoading] = useState(null) - - const [error, setError] = useState(null) const [newOrganisationError, setNewOrganisationError] = useState(null) const isNewOrganisationLoading = fetcher.state === 'submitting' + const organisations = loaderData?.organisations || [] + const paginationTotal = loaderData?.total || 0 + const page = loaderData?.page || 1 + const error = loaderData?.error + const pageAmount = Math.ceil(paginationTotal / ENTRIES_PER_PAGE_DASHBOARD) + const updateUrlParams = (newPage: number, newSearch: string) => { + const params = new URLSearchParams() + if (newPage > 1) params.set('page', String(newPage)) + if (newSearch) params.set('search', newSearch) + navigate(`${routes.organisations}${params.toString() ? `?${params.toString()}` : ''}`, { replace: true }) + } + + const closeNewOrganisationModal = () => { + if (isNewOrganisationLoading) { + return + } + + setNewOrganisationModalOpen(false) + setNewOrganisationError(null) + setNewOrganisationName('') + } + + useEffect(() => { + if (debouncedSearch !== loaderData?.search) { + updateUrlParams(1, debouncedSearch) + } + }, [debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps + // Handle fetcher responses + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (fetcher.data?.success && fetcher.data?.intent === 'create-organisation') { - // Reload organisations - loadOrganisations(ENTRIES_PER_PAGE_DASHBOARD, (page - 1) * ENTRIES_PER_PAGE_DASHBOARD, debouncedSearch) + updateUrlParams(page, debouncedSearch) toast.success(t('apiNotifications.organisationCreated')) closeNewOrganisationModal() + } else if (fetcher.data?.success && fetcher.data?.intent === 'accept-invitation') { + updateUrlParams(page, debouncedSearch) + toast.success(t('apiNotifications.acceptOrganisationInvitation')) } else if (fetcher.data?.error && fetcher.data?.intent === 'create-organisation') { setNewOrganisationError(fetcher.data.error) toast.error(fetcher.data.error) } else if (fetcher.data?.fieldErrors?.name && fetcher.data?.intent === 'create-organisation') { setNewOrganisationError(fetcher.data.fieldErrors.name) + } else if (fetcher.data?.error) { + toast.error(fetcher.data.error) } }, [fetcher.data, page, debouncedSearch, t]) // eslint-disable-line react-hooks/exhaustive-deps + /* eslint-enable react-hooks/set-state-in-effect */ const onNewOrganisation = () => { setNewOrganisationModalOpen(true) @@ -84,54 +111,11 @@ const Organisations = () => { fetcher.submit(formData, { method: 'post' }) } - const closeNewOrganisationModal = () => { - if (isNewOrganisationLoading) { - return - } - - setNewOrganisationModalOpen(false) - setNewOrganisationError(null) - setNewOrganisationName('') + const setPage = (newPage: number) => { + updateUrlParams(newPage, debouncedSearch) } - const loadOrganisations = async (take: number, skip: number, search?: string) => { - if (isLoading) { - return - } - setIsLoading(true) - - try { - const result = await getOrganisations(take, skip, search) - setOrganisations(result.results) - setPaginationTotal(result.total) - } catch (reason: any) { - setError(reason) - } finally { - setIsLoading(false) - } - } - - const reloadOrganisations = useCallback(async () => { - try { - const result = await getOrganisations( - ENTRIES_PER_PAGE_DASHBOARD, - (page - 1) * ENTRIES_PER_PAGE_DASHBOARD, - debouncedSearch, - ) - setOrganisations(result.results) - setPaginationTotal(result.total) - } catch (reason: any) { - console.error(`[ERROR] Error while reloading organisations: ${reason}`) - } - }, [debouncedSearch, page]) - - useEffect(() => { - loadOrganisations(ENTRIES_PER_PAGE_DASHBOARD, (page - 1) * ENTRIES_PER_PAGE_DASHBOARD, debouncedSearch) - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, debouncedSearch]) - - if (error && isLoading === false) { + if (error) { return ( {
) : null} - {authLoading || isLoading || isLoading === null ? ( + {authLoading ? (
@@ -269,11 +253,7 @@ const Organisations = () => { ) : (
{_map(organisations, (organisation) => ( - + ))}
diff --git a/web/app/pages/Organisations/Settings/People.tsx b/web/app/pages/Organisations/Settings/People.tsx index a4e95995e..bb1a73d49 100644 --- a/web/app/pages/Organisations/Settings/People.tsx +++ b/web/app/pages/Organisations/Settings/People.tsx @@ -7,13 +7,14 @@ import _map from 'lodash/map' import { Trash2Icon, UserRoundPlusIcon } from 'lucide-react' import React, { useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { changeOrganisationRole, inviteOrganisationMember, removeOrganisationMember } from '~/api' import useOnClickOutside from '~/hooks/useOnClickOutside' import { roles, INVITATION_EXPIRES_IN } from '~/lib/constants' import { DetailedOrganisation, Role } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' +import type { OrganisationSettingsActionData } from '~/routes/organisations.$id' import { Badge } from '~/ui/Badge' import Button from '~/ui/Button' import Input from '~/ui/Input' @@ -35,10 +36,10 @@ const NoPeople = () => { interface UsersListProps { members: DetailedOrganisation['members'] onRemove: (member: DetailedOrganisation['members'][number]) => void - reloadOrganisation: () => Promise + fetcher: ReturnType> } -const UsersList = ({ members, onRemove, reloadOrganisation }: UsersListProps) => { +const UsersList = ({ members, onRemove, fetcher }: UsersListProps) => { const { t, i18n: { language }, @@ -49,16 +50,12 @@ const UsersList = ({ members, onRemove, reloadOrganisation }: UsersListProps) => const openRef = useRef(null) useOnClickOutside(openRef, () => setRoleEditDropdownId(null)) - const changeRole = async (memberId: string, newRole: Role) => { - try { - await changeOrganisationRole(memberId, newRole) - await reloadOrganisation() - toast.success(t('apiNotifications.roleUpdated')) - } catch (reason) { - console.error(`[ERROR] Error while updating user's role: ${reason}`) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.roleUpdateError')) - } - + const changeRole = (memberId: string, newRole: Role) => { + const formData = new FormData() + formData.set('intent', 'update-member-role') + formData.set('memberId', memberId) + formData.set('role', newRole) + fetcher.submit(formData, { method: 'post' }) setRoleEditDropdownId(null) } @@ -148,12 +145,13 @@ const UsersList = ({ members, onRemove, reloadOrganisation }: UsersListProps) => interface PeopleProps { organisation: DetailedOrganisation - reloadOrganisation: () => Promise } -const People = ({ organisation, reloadOrganisation }: PeopleProps) => { +const People = ({ organisation }: PeopleProps) => { const [showModal, setShowModal] = useState(false) const { t } = useTranslation('common') + const fetcher = useFetcher() + const [form, setForm] = useState<{ email: string role: Role @@ -169,11 +167,44 @@ const People = ({ organisation, reloadOrganisation }: PeopleProps) => { const [validated, setValidated] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) const [memberToRemove, setMemberToRemove] = useState(null) const { name, members } = organisation + const isSubmitting = fetcher.state === 'submitting' + const isDeleting = isSubmitting && fetcher.formData?.get('intent') === 'remove-member' + + const closeModal = () => { + setShowModal(false) + setTimeout(() => setForm({ email: '', role: 'viewer' }), 300) + setErrors({}) + setBeenSubmitted(false) + } + + const closeRemoveUserModal = () => { + setShowDeleteModal(false) + setTimeout(() => setMemberToRemove(null), 300) + } + + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (fetcher.data?.success) { + const { intent } = fetcher.data + if (intent === 'invite-member') { + toast.success(t('apiNotifications.userInvited')) + closeModal() + } else if (intent === 'remove-member') { + toast.success(t('apiNotifications.orgUserRemoved')) + closeRemoveUserModal() + } else if (intent === 'update-member-role') { + toast.success(t('apiNotifications.roleUpdated')) + } + } else if (fetcher.data?.error) { + toast.error(fetcher.data.error) + } + }, [fetcher.data, t]) + /* eslint-enable react-hooks/set-state-in-effect */ + const validate = () => { const allErrors: { email?: string @@ -194,11 +225,13 @@ const People = ({ organisation, reloadOrganisation }: PeopleProps) => { setValidated(valid) } + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (showModal) { validate() } - }, [form]) // eslint-disable-line + }, [form]) // eslint-disable-line react-hooks/exhaustive-deps + /* eslint-enable react-hooks/set-state-in-effect */ const handleInput = ({ target }: React.ChangeEvent) => { const value = target.type === 'checkbox' ? target.checked : target.value @@ -209,22 +242,12 @@ const People = ({ organisation, reloadOrganisation }: PeopleProps) => { })) } - const onInviteToOrganisation = async () => { - setShowModal(false) - setErrors({}) - setValidated(false) - - try { - await inviteOrganisationMember(organisation.id, form) - await reloadOrganisation() - toast.success(t('apiNotifications.userInvited')) - } catch (reason) { - console.error(`[ERROR] Error while inviting a user: ${reason}`) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.userInviteError')) - } - - // a timeout is needed to prevent the flicker of data fields in the modal when closing - setTimeout(() => setForm({ email: '', role: 'viewer' }), 300) + const onInviteToOrganisation = () => { + const formData = new FormData() + formData.set('intent', 'invite-member') + formData.set('email', form.email) + formData.set('role', form.role) + fetcher.submit(formData, { method: 'post' }) } const handleSubmit = (e: React.MouseEvent) => { @@ -239,36 +262,11 @@ const People = ({ organisation, reloadOrganisation }: PeopleProps) => { } } - const closeModal = () => { - setShowModal(false) - // a timeout is needed to prevent the flicker of data fields in the modal when closing - setTimeout(() => setForm({ email: '', role: 'viewer' }), 300) - setErrors({}) - } - - const closeRemoveUserModal = () => { - setShowDeleteModal(false) - setTimeout(() => setMemberToRemove(null), 300) - } - - const onRemove = async (member: DetailedOrganisation['members'][number]) => { - if (isDeleting) { - return - } - - setIsDeleting(true) - - try { - await removeOrganisationMember(member.id) - await reloadOrganisation() - - toast.success(t('apiNotifications.orgUserRemoved')) - } catch (reason) { - console.error(`[ERROR] Error while removing user from the organisation: ${reason}`) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.orgUserRemoveError')) - } finally { - setIsDeleting(false) - } + const onRemove = (member: DetailedOrganisation['members'][number]) => { + const formData = new FormData() + formData.set('intent', 'remove-member') + formData.set('memberId', member.id) + fetcher.submit(formData, { method: 'post' }) } return ( @@ -320,7 +318,7 @@ const People = ({ organisation, reloadOrganisation }: PeopleProps) => { setMemberToRemove(member) setShowDeleteModal(true) }} - reloadOrganisation={reloadOrganisation} + fetcher={fetcher} /> diff --git a/web/app/pages/Organisations/Settings/Projects.tsx b/web/app/pages/Organisations/Settings/Projects.tsx index 258602aaa..d060b81df 100644 --- a/web/app/pages/Organisations/Settings/Projects.tsx +++ b/web/app/pages/Organisations/Settings/Projects.tsx @@ -5,14 +5,15 @@ import _map from 'lodash/map' import _replace from 'lodash/replace' import { useState, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router' +import { Link, useFetcher } from 'react-router' import { toast } from 'sonner' -import { removeProjectFromOrganisation, getProjectsAvailableForOrganisation, addProjectToOrganisation } from '~/api' +import { getProjectsAvailableForOrganisation } from '~/api' import useDebounce from '~/hooks/useDebounce' import { DetailedOrganisation } from '~/lib/models/Organisation' import { Project } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' +import type { OrganisationSettingsActionData } from '~/routes/organisations.$id' import Button from '~/ui/Button' import Loader from '~/ui/Loader' import Modal from '~/ui/Modal' @@ -169,17 +170,15 @@ const ProjectList = ({ projects, onRemove }: ProjectListProps) => { interface ProjectsProps { organisation: DetailedOrganisation - reloadOrganisation: () => Promise } -export const Projects = ({ organisation, reloadOrganisation }: ProjectsProps) => { +export const Projects = ({ organisation }: ProjectsProps) => { const { t } = useTranslation('common') + const fetcher = useFetcher() const [showDeleteModal, setShowDeleteModal] = useState(false) const [showAddProjectModal, setShowAddProjectModal] = useState(false) const [projectToRemove, setProjectToRemove] = useState(null) - - const [isActionLoading, setIsActionLoading] = useState(false) const [selectedProject, setSelectedProject] = useState(null) const [currentPage, setCurrentPage] = useState(1) @@ -187,6 +186,29 @@ export const Projects = ({ organisation, reloadOrganisation }: ProjectsProps) => const [isSearchActive, setIsSearchActive] = useState(false) const { projects } = organisation + const isSubmitting = fetcher.state === 'submitting' + const isAddingProject = isSubmitting && fetcher.formData?.get('intent') === 'add-project' + const isRemovingProject = isSubmitting && fetcher.formData?.get('intent') === 'remove-project' + + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (fetcher.data?.success) { + const { intent } = fetcher.data + if (intent === 'add-project') { + toast.success(t('apiNotifications.projectAddedToOrganisation')) + setShowAddProjectModal(false) + setSelectedProject(null) + } else if (intent === 'remove-project') { + toast.success(t('apiNotifications.projectRemovedFromOrganisation')) + setShowDeleteModal(false) + setProjectToRemove(null) + } + } else if (fetcher.data?.error) { + toast.error(fetcher.data.error) + } + }, [fetcher.data, t]) + /* eslint-enable react-hooks/set-state-in-effect */ + const filteredProjects = useMemo(() => { if (!search) { return projects @@ -197,46 +219,20 @@ export const Projects = ({ organisation, reloadOrganisation }: ProjectsProps) => const pageAmount = Math.ceil(filteredProjects.length / PROJECT_LIST_PAGE_SIZE) - const removeProject = async (projectId: string) => { - if (isActionLoading) { - return - } - - setIsActionLoading(true) - - try { - await removeProjectFromOrganisation(organisation.id, projectId) - await reloadOrganisation() - toast.success(t('apiNotifications.projectRemovedFromOrganisation')) - } catch (reason) { - console.error(`[ERROR] Error while deleting a project: ${reason}`) - toast.error(t('apiNotifications.projectRemoveError')) - } finally { - setProjectToRemove(null) - setIsActionLoading(false) - } + const removeProject = (projectId: string) => { + const formData = new FormData() + formData.set('intent', 'remove-project') + formData.set('projectId', projectId) + fetcher.submit(formData, { method: 'post' }) } - const onAddProject = async () => { - setShowAddProjectModal(false) - - if (isActionLoading) { - return - } + const onAddProject = () => { + if (!selectedProject) return - setIsActionLoading(true) - - try { - await addProjectToOrganisation(organisation.id, selectedProject.id) - await reloadOrganisation() - toast.success(t('apiNotifications.projectAddedToOrganisation')) - } catch (reason) { - console.error(`[ERROR] Error while adding a project: ${reason}`) - toast.error(t('apiNotifications.projectAddError')) - } finally { - setSelectedProject(null) - setIsActionLoading(false) - } + const formData = new FormData() + formData.set('intent', 'add-project') + formData.set('projectId', selectedProject.id) + fetcher.submit(formData, { method: 'post' }) } return ( @@ -353,7 +349,7 @@ export const Projects = ({ organisation, reloadOrganisation }: ProjectsProps) => type='button' className='inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium text-white hover:bg-indigo-700 sm:ml-3 sm:w-auto sm:text-sm' onClick={onAddProject} - disabled={!selectedProject || isActionLoading} + disabled={!selectedProject || isAddingProject} > {t('organisations.addProject')} @@ -383,7 +379,7 @@ export const Projects = ({ organisation, reloadOrganisation }: ProjectsProps) =>
} isOpened={showAddProjectModal} - isLoading={isActionLoading} + isLoading={isAddingProject} /> setShowDeleteModal(false) setProjectToRemove(null) }} - onSubmit={async () => { - await removeProject(projectToRemove!.id) - setShowDeleteModal(false) + onSubmit={() => { + removeProject(projectToRemove!.id) }} submitText={t('common.yes')} closeText={t('common.no')} @@ -401,7 +396,7 @@ export const Projects = ({ organisation, reloadOrganisation }: ProjectsProps) => message={t('organisations.modals.remove.message')} isOpened={showDeleteModal} type='warning' - isLoading={isActionLoading} + isLoading={isRemovingProject} />
) diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index cca63bf48..525be6edf 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -2,17 +2,16 @@ import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' import _size from 'lodash/size' import { Trash2Icon } from 'lucide-react' -import React, { useState, useEffect, useCallback, useMemo } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useNavigate, useFetcher } from 'react-router' +import { Link, useNavigate, useFetcher, useLoaderData, useRevalidator } from 'react-router' import { toast } from 'sonner' -import { getOrganisation, transferProject } from '~/api' -import { useRequiredParams } from '~/hooks/useRequiredParams' +import { transferProject } from '~/api' import { TITLE_SUFFIX } from '~/lib/constants' import { DetailedOrganisation } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' -import type { OrganisationSettingsActionData } from '~/routes/organisations.$id' +import type { OrganisationSettingsActionData, OrganisationLoaderData } from '~/routes/organisations.$id' import Button from '~/ui/Button' import Input from '~/ui/Input' import Loader from '~/ui/Loader' @@ -23,19 +22,18 @@ import routes from '~/utils/routes' import People from './People' import { Projects } from './Projects' -// import { ArrowLeftRight } from 'lucide-react' - const MAX_NAME_LENGTH = 50 const DEFAULT_ORGANISATION_NAME = 'Untitled Organisation' const OrganisationSettings = () => { const { t } = useTranslation('common') - const { id } = useRequiredParams<{ id: string }>() + const loaderData = useLoaderData() const navigate = useNavigate() const fetcher = useFetcher() + const revalidator = useRevalidator() - const { user, isLoading: authLoading } = useAuth() + const { user } = useAuth() const [validated, setValidated] = useState(false) const [errors, setErrors] = useState<{ @@ -46,40 +44,54 @@ const OrganisationSettings = () => { const [showTransfer, setShowTransfer] = useState(false) const [transferEmail, setTransferEmail] = useState('') - const [isLoading, setIsLoading] = useState(null) - const [error, setError] = useState(null) + const organisation = loaderData?.organisation || null + const error = loaderData?.error - const [organisation, setOrganisation] = useState(null) - const [form, setForm] = useState>({ - name: '', - }) + const [form, setForm] = useState>(() => ({ + name: organisation?.name || '', + })) const isSaving = fetcher.state === 'submitting' const isDeleting = fetcher.state === 'submitting' && fetcher.formData?.get('intent') === 'delete-organisation' // Handle fetcher responses + if (fetcher.data?.success && fetcher.state === 'idle') { + const { intent, organisation: updatedOrg } = fetcher.data + + if (intent === 'update-organisation') { + if (updatedOrg && form.name !== updatedOrg.name) { + setForm({ name: updatedOrg.name }) + } + } + } + + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (fetcher.data?.success) { - const { intent, organisation: updatedOrg } = fetcher.data + const { intent } = fetcher.data if (intent === 'update-organisation') { - if (updatedOrg) { - setOrganisation(updatedOrg) - } toast.success(t('apiNotifications.orgSettingsUpdated')) + revalidator.revalidate() } else if (intent === 'delete-organisation') { toast.success(t('apiNotifications.organisationDeleted')) setShowDelete(false) navigate(routes.organisations) - } else if (intent === 'invite-member' || intent === 'remove-member' || intent === 'update-member-role') { - reloadOrganisation() - toast.success(t('apiNotifications.orgSettingsUpdated')) + } else if ( + intent === 'invite-member' || + intent === 'remove-member' || + intent === 'update-member-role' || + intent === 'add-project' || + intent === 'remove-project' + ) { + revalidator.revalidate() } } else if (fetcher.data?.error) { toast.error(fetcher.data.error) setShowDelete(false) } }, [fetcher.data, t, navigate]) // eslint-disable-line react-hooks/exhaustive-deps + /* eslint-enable react-hooks/set-state-in-effect */ const isOrganisationOwner = useMemo(() => { if (!organisation) { @@ -91,47 +103,6 @@ const OrganisationSettings = () => { return owner?.user?.email === user?.email }, [organisation, user]) - const loadOrganisation = async (organisationId: string) => { - if (isLoading) { - return - } - setIsLoading(true) - - try { - const result = await getOrganisation(organisationId) - setOrganisation(result) - setForm({ - name: result.name, - }) - } catch (reason: any) { - setError(reason) - } finally { - setIsLoading(false) - } - } - - const reloadOrganisation = useCallback(async () => { - try { - const result = await getOrganisation(id) - setOrganisation(result) - setForm({ - name: result.name, - }) - } catch (reason: any) { - console.error(`[ERROR] Error while reloading organisation: ${reason}`) - } - }, [id]) - - useEffect(() => { - if (authLoading) { - return - } - - loadOrganisation(id) - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [authLoading, id]) - const onSubmit = (formData: Pick) => { if (fetcher.state === 'submitting') return @@ -171,9 +142,11 @@ const OrganisationSettings = () => { setValidated(valid) } + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { validate() - }, [form]) // eslint-disable-line + }, [form]) // eslint-disable-line react-hooks/exhaustive-deps + /* eslint-enable react-hooks/set-state-in-effect */ const handleInput = (event: React.ChangeEvent) => { const { target } = event @@ -196,7 +169,7 @@ const OrganisationSettings = () => { } const onTransfer = async () => { - await transferProject(id, transferEmail) + await transferProject(organisation?.id || '', transferEmail) .then(() => { toast.success(t('apiNotifications.transferRequestSent')) navigate(routes.dashboard) @@ -216,7 +189,7 @@ const OrganisationSettings = () => { document.title = `${t('project.settings.settings')} ${form.name} ${TITLE_SUFFIX}` }, [form, t]) - if (isLoading || isLoading === null || !organisation) { + if (!organisation) { return (
@@ -224,7 +197,7 @@ const OrganisationSettings = () => { ) } - if (error && !isLoading) { + if (error) { return ( { ) : null}

- - + + setShowDelete(false)} diff --git a/web/app/routes/organisations.$id.tsx b/web/app/routes/organisations.$id.tsx index 94b167e03..ad6375a3a 100644 --- a/web/app/routes/organisations.$id.tsx +++ b/web/app/routes/organisations.$id.tsx @@ -12,13 +12,33 @@ export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export async function loader({ request }: LoaderFunctionArgs) { +export interface OrganisationLoaderData { + organisation: DetailedOrganisation | null + error?: string +} + +export async function loader({ request, params }: LoaderFunctionArgs) { if (isSelfhosted) { return redirect('/login', 302) } redirectIfNotAuthenticated(request) - return null + + const { id } = params + + const result = await serverFetch(request, `organisation/${id}`) + + if (result.error) { + return data( + { organisation: null, error: result.error as string }, + { status: result.status, headers: createHeadersWithCookies(result.cookies) }, + ) + } + + return data( + { organisation: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) } export interface OrganisationSettingsActionData { @@ -102,7 +122,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } - const result = await serverFetch(request, `organisation/${id}/members`, { + const result = await serverFetch(request, `organisation/${id}/invite`, { method: 'POST', body: { email, role }, }) @@ -120,7 +140,7 @@ export async function action({ request, params }: ActionFunctionArgs) { case 'remove-member': { const memberId = formData.get('memberId')?.toString() - const result = await serverFetch(request, `organisation/${id}/members/${memberId}`, { + const result = await serverFetch(request, `organisation/member/${memberId}`, { method: 'DELETE', }) @@ -138,7 +158,7 @@ export async function action({ request, params }: ActionFunctionArgs) { const memberId = formData.get('memberId')?.toString() const role = formData.get('role')?.toString() - const result = await serverFetch(request, `organisation/${id}/members/${memberId}`, { + const result = await serverFetch(request, `organisation/member/${memberId}`, { method: 'PATCH', body: { role }, }) @@ -153,6 +173,49 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } + case 'add-project': { + const projectId = formData.get('projectId')?.toString() + + if (!projectId) { + return data({ intent, error: 'Project ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/organisation/${id}`, { + method: 'POST', + body: { projectId }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'remove-project': { + const projectId = formData.get('projectId')?.toString() + + if (!projectId) { + return data({ intent, error: 'Project ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/organisation/${id}/${projectId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } diff --git a/web/app/routes/organisations._index.tsx b/web/app/routes/organisations._index.tsx index 939241c7d..808d08df3 100644 --- a/web/app/routes/organisations._index.tsx +++ b/web/app/routes/organisations._index.tsx @@ -3,7 +3,7 @@ import { redirect, data } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' import { serverFetch } from '~/api/api.server' -import { isSelfhosted } from '~/lib/constants' +import { ENTRIES_PER_PAGE_DASHBOARD, isSelfhosted } from '~/lib/constants' import { DetailedOrganisation } from '~/lib/models/Organisation' import Organisations from '~/pages/Organisations' import { redirectIfNotAuthenticated, createHeadersWithCookies } from '~/utils/session.server' @@ -12,13 +12,52 @@ export const sitemap: SitemapFunction = () => ({ exclude: true, }) +interface OrganisationsResponse { + results: DetailedOrganisation[] + total: number +} + +export interface OrganisationsLoaderData { + organisations: DetailedOrganisation[] + total: number + page: number + search: string + error?: string +} + export async function loader({ request }: LoaderFunctionArgs) { if (isSelfhosted) { return redirect('/login', 302) } redirectIfNotAuthenticated(request) - return null + + const url = new URL(request.url) + const page = parseInt(url.searchParams.get('page') || '1', 10) + const search = url.searchParams.get('search') || '' + const skip = (page - 1) * ENTRIES_PER_PAGE_DASHBOARD + + const result = await serverFetch( + request, + `organisation?take=${ENTRIES_PER_PAGE_DASHBOARD}&skip=${skip}&search=${encodeURIComponent(search)}`, + ) + + if (result.error) { + return data( + { organisations: [], total: 0, page, search, error: result.error as string }, + { status: 400, headers: createHeadersWithCookies(result.cookies) }, + ) + } + + return data( + { + organisations: result.data?.results || [], + total: result.data?.total || 0, + page, + search, + }, + { headers: createHeadersWithCookies(result.cookies) }, + ) } export interface OrganisationsActionData { @@ -74,6 +113,48 @@ export async function action({ request }: ActionFunctionArgs) { ) } + case 'accept-invitation': { + const membershipId = formData.get('membershipId')?.toString() + + if (!membershipId) { + return data({ intent, error: 'Membership ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `user/organisation/${membershipId}`, { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'reject-invitation': { + const membershipId = formData.get('membershipId')?.toString() + + if (!membershipId) { + return data({ intent, error: 'Membership ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `user/organisation/${membershipId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } From b0ea9081f0e792fafafd32b208db67be0a875483 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 4 Jan 2026 23:00:48 +0000 Subject: [PATCH 08/57] Fix mltiple form submissions show only a single popup --- .../pages/Auth/CreateNewPassword/CreateNewPassword.tsx | 2 +- web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx | 2 +- web/app/pages/Auth/Signin/Signin.tsx | 2 +- web/app/pages/Auth/Signup/Signup.tsx | 2 +- web/app/routes/login.tsx | 9 +++++---- web/app/routes/password-reset.$id.tsx | 5 +++-- web/app/routes/recovery.tsx | 5 +++-- web/app/routes/signup.tsx | 5 +++-- 8 files changed, 18 insertions(+), 14 deletions(-) diff --git a/web/app/pages/Auth/CreateNewPassword/CreateNewPassword.tsx b/web/app/pages/Auth/CreateNewPassword/CreateNewPassword.tsx index 6d6a72430..a54318e4f 100644 --- a/web/app/pages/Auth/CreateNewPassword/CreateNewPassword.tsx +++ b/web/app/pages/Auth/CreateNewPassword/CreateNewPassword.tsx @@ -20,7 +20,7 @@ const CreateNewPassword = () => { if (actionData?.error) { toast.error(actionData.error) } - }, [actionData?.error]) + }, [actionData?.error, actionData?.timestamp]) return (
diff --git a/web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx b/web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx index a7deeed60..da5813ddf 100644 --- a/web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx +++ b/web/app/pages/Auth/ForgotPassword/ForgotPassword.tsx @@ -27,7 +27,7 @@ const ForgotPassword = () => { if (actionData?.error) { toast.error(actionData.error) } - }, [actionData?.error]) + }, [actionData?.error, actionData?.timestamp]) return (
diff --git a/web/app/pages/Auth/Signin/Signin.tsx b/web/app/pages/Auth/Signin/Signin.tsx index 8158f6f1f..fee223899 100644 --- a/web/app/pages/Auth/Signin/Signin.tsx +++ b/web/app/pages/Auth/Signin/Signin.tsx @@ -62,7 +62,7 @@ const Signin = () => { const errorMessage = Array.isArray(actionData.error) ? actionData.error[0] : actionData.error toast.error(errorMessage) } - }, [actionData?.error, actionData?.fieldErrors]) + }, [actionData?.error, actionData?.fieldErrors, actionData?.timestamp]) const clearFieldError = (fieldName: string) => { if (actionData?.fieldErrors?.[fieldName as keyof typeof actionData.fieldErrors]) { diff --git a/web/app/pages/Auth/Signup/Signup.tsx b/web/app/pages/Auth/Signup/Signup.tsx index efd1e1de7..d5adb3f65 100644 --- a/web/app/pages/Auth/Signup/Signup.tsx +++ b/web/app/pages/Auth/Signup/Signup.tsx @@ -54,7 +54,7 @@ const Signup = () => { const errorMessage = Array.isArray(actionData.error) ? actionData.error[0] : actionData.error toast.error(errorMessage) } - }, [actionData?.error, actionData?.fieldErrors]) + }, [actionData?.error, actionData?.fieldErrors, actionData?.timestamp]) const clearFieldError = (fieldName: string) => { if (actionData?.fieldErrors?.[fieldName as keyof typeof actionData.fieldErrors]) { diff --git a/web/app/routes/login.tsx b/web/app/routes/login.tsx index b4d38f0be..38d870ff3 100644 --- a/web/app/routes/login.tsx +++ b/web/app/routes/login.tsx @@ -32,6 +32,7 @@ export interface LoginActionData { password?: string twoFACode?: string } + timestamp?: number } export async function action({ request }: ActionFunctionArgs) { @@ -44,7 +45,7 @@ export async function action({ request }: ActionFunctionArgs) { const dontRemember = formData.get('dontRemember') === 'true' if (!twoFactorAuthenticationCode || twoFactorAuthenticationCode.length !== 6) { - return data({ fieldErrors: { twoFACode: 'Please enter a valid 6-digit code' } }, { status: 400 }) + return data({ fieldErrors: { twoFACode: 'Please enter a valid 6-digit code' }, timestamp: Date.now() }, { status: 400 }) } const result = await serverFetch<{ accessToken: string; refreshToken: string; user: { hasCompletedOnboarding: boolean } }>( @@ -57,7 +58,7 @@ export async function action({ request }: ActionFunctionArgs) { ) if (result.error) { - return data({ fieldErrors: { twoFACode: 'Invalid 2FA code' } }, { status: 400 }) + return data({ fieldErrors: { twoFACode: 'Invalid 2FA code' }, timestamp: Date.now() }, { status: 400 }) } const { accessToken, refreshToken, user } = result.data! @@ -86,13 +87,13 @@ export async function action({ request }: ActionFunctionArgs) { } if (fieldErrors.email || fieldErrors.password) { - return data({ fieldErrors }, { status: 400 }) + return data({ fieldErrors, timestamp: Date.now() }, { status: 400 }) } const result = await loginUser(request, { email, password }, !dontRemember) if (!result.success) { - return data({ error: result.error }, { status: 400 }) + return data({ error: result.error, timestamp: Date.now() }, { status: 400 }) } if (result.requires2FA) { diff --git a/web/app/routes/password-reset.$id.tsx b/web/app/routes/password-reset.$id.tsx index e9192d16a..76fdab618 100644 --- a/web/app/routes/password-reset.$id.tsx +++ b/web/app/routes/password-reset.$id.tsx @@ -22,6 +22,7 @@ export interface CreateNewPasswordActionData { password?: string repeat?: string } + timestamp?: number } export async function action({ request, params }: ActionFunctionArgs) { @@ -45,7 +46,7 @@ export async function action({ request, params }: ActionFunctionArgs) { } if (fieldErrors.password || fieldErrors.repeat) { - return data({ fieldErrors }, { status: 400 }) + return data({ fieldErrors, timestamp: Date.now() }, { status: 400 }) } const result = await serverFetch(request, `v1/auth/reset-password/confirm/${id}`, { @@ -55,7 +56,7 @@ export async function action({ request, params }: ActionFunctionArgs) { }) if (result.error) { - return data({ error: result.error as string }, { status: 400 }) + return data({ error: result.error as string, timestamp: Date.now() }, { status: 400 }) } return redirect('/login?password_updated=true') diff --git a/web/app/routes/recovery.tsx b/web/app/routes/recovery.tsx index e57b311c8..4ba78fc8b 100644 --- a/web/app/routes/recovery.tsx +++ b/web/app/routes/recovery.tsx @@ -21,6 +21,7 @@ export interface ForgotPasswordActionData { fieldErrors?: { email?: string } + timestamp?: number } export async function action({ request }: ActionFunctionArgs) { @@ -29,7 +30,7 @@ export async function action({ request }: ActionFunctionArgs) { if (!isValidEmail(email)) { return data( - { fieldErrors: { email: 'Please enter a valid email address' } }, + { fieldErrors: { email: 'Please enter a valid email address' }, timestamp: Date.now() }, { status: 400 }, ) } @@ -41,7 +42,7 @@ export async function action({ request }: ActionFunctionArgs) { }) if (result.error) { - return data({ error: result.error as string }, { status: 400 }) + return data({ error: result.error as string, timestamp: Date.now() }, { status: 400 }) } return redirect('/?password_reset_sent=true') diff --git a/web/app/routes/signup.tsx b/web/app/routes/signup.tsx index 5af28f8fa..96b97f4df 100644 --- a/web/app/routes/signup.tsx +++ b/web/app/routes/signup.tsx @@ -38,6 +38,7 @@ export interface SignupActionData { repeat?: string tos?: string } + timestamp?: number } export async function action({ request }: ActionFunctionArgs) { @@ -72,13 +73,13 @@ export async function action({ request }: ActionFunctionArgs) { } if (fieldErrors.email || fieldErrors.password || fieldErrors.repeat || fieldErrors.tos) { - return data({ fieldErrors }, { status: 400 }) + return data({ fieldErrors, timestamp: Date.now() }, { status: 400 }) } const result = await registerUser(request, { email, password, checkIfLeaked }, true) if (!result.success) { - return data({ error: result.error }, { status: 400 }) + return data({ error: result.error, timestamp: Date.now() }, { status: 400 }) } return redirect('/onboarding', { From d02301e2485fa8c6e04372f21a07750269d9dacb Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sun, 4 Jan 2026 23:11:35 +0000 Subject: [PATCH 09/57] fix: uneditable org name --- web/app/pages/Organisations/Settings/index.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index 525be6edf..6368be5d9 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -54,17 +54,6 @@ const OrganisationSettings = () => { const isSaving = fetcher.state === 'submitting' const isDeleting = fetcher.state === 'submitting' && fetcher.formData?.get('intent') === 'delete-organisation' - // Handle fetcher responses - if (fetcher.data?.success && fetcher.state === 'idle') { - const { intent, organisation: updatedOrg } = fetcher.data - - if (intent === 'update-organisation') { - if (updatedOrg && form.name !== updatedOrg.name) { - setForm({ name: updatedOrg.name }) - } - } - } - /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (fetcher.data?.success) { From d27a7761e7f71905e16421878202c214218a652e Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 5 Jan 2026 00:01:50 +0000 Subject: [PATCH 10/57] Migrate project related stuff --- web/app/api/index.ts | 32 ------ web/app/pages/Dashboard/ProjectCard.tsx | 99 ++++++++++--------- .../pages/Organisations/Settings/Projects.tsx | 37 ++++--- .../pages/Organisations/Settings/index.tsx | 48 --------- web/app/routes/dashboard.tsx | 64 ++++++++++++ web/app/routes/organisations.$id.tsx | 32 ++++++ 6 files changed, 165 insertions(+), 147 deletions(-) diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 770cb901d..a791f68df 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -99,14 +99,6 @@ export const getProjects = (take = 0, skip = 0, search = '', period = '7d', sort throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getProjectsAvailableForOrganisation = (take = 0, skip = 0, search?: string) => - api - .get(`/project/available-for-organisation?take=${take}&skip=${skip}&search=${search}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const acceptOrganisationInvitation = (tokenId: string) => api .post(`/user/organisation/${tokenId}`) @@ -135,22 +127,6 @@ export const getProject = (pid: string, password?: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const pinProject = (id: string) => - api - .post(`/project/${id}/pin`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const unpinProject = (id: string) => - api - .delete(`/project/${id}/pin`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProjectData = ( pid: string, tb = 'hour', @@ -773,14 +749,6 @@ export const rejectProjectShare = (actionId: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const transferProject = (projectId: string, email: string) => - api - .post('/project/transfer', { projectId, email }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const acceptProjectShare = (actionId: string) => api .get(`/user/share/${actionId}`) diff --git a/web/app/pages/Dashboard/ProjectCard.tsx b/web/app/pages/Dashboard/ProjectCard.tsx index 81d367a10..339d47757 100644 --- a/web/app/pages/Dashboard/ProjectCard.tsx +++ b/web/app/pages/Dashboard/ProjectCard.tsx @@ -7,10 +7,10 @@ import _size from 'lodash/size' import { Settings2Icon, PinIcon, ChevronUpIcon, ChevronDownIcon } from 'lucide-react' import React, { useState, useMemo, useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from 'react-router' +import { Link, useFetcher } from 'react-router' import { toast } from 'sonner' -import { acceptProjectShare, pinProject, unpinProject } from '~/api' +import type { DashboardActionData } from '~/routes/dashboard' import { OverallObject, Project } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' import { Badge, BadgeProps } from '~/ui/Badge' @@ -123,6 +123,7 @@ export const ProjectCard = ({ }: ProjectCardProps) => { const { t } = useTranslation('common') const [showInviteModal, setShowInviteModal] = useState(false) + const fetcher = useFetcher() const { user, mergeUser } = useAuth() const isTouchDevice = useIsTouchDevice() @@ -146,46 +147,65 @@ export const ProjectCard = ({ } = project const faviconHost = useMemo(() => getFaviconHost(websiteUrl || null), [websiteUrl]) - const [isPinning, setIsPinning] = useState(false) const [localIsPinned, setLocalIsPinned] = useState(isPinned) + const isPinning = fetcher.state === 'submitting' && + (fetcher.formData?.get('intent') === 'pin-project' || fetcher.formData?.get('intent') === 'unpin-project') + // Sync local state with prop useEffect(() => { setLocalIsPinned(isPinned) }, [isPinned]) + // Handle fetcher responses + useEffect(() => { + if (fetcher.data?.success) { + const { intent } = fetcher.data + + if (intent === 'pin-project') { + toast.success(t('dashboard.pinned')) + refetchProjects() + } else if (intent === 'unpin-project') { + toast.success(t('dashboard.unpinned')) + refetchProjects() + } else if (intent === 'accept-project-share') { + mergeUser({ + sharedProjects: user?.sharedProjects?.map((item) => { + if (item.id === shareId) { + return { ...item, isAccessConfirmed: true } + } + return item + }), + }) + refetchProjects() + toast.success(t('apiNotifications.acceptInvitation')) + } + } else if (fetcher.data?.error) { + // Revert optimistic update on error + if (fetcher.data.intent === 'pin-project' || fetcher.data.intent === 'unpin-project') { + setLocalIsPinned(isPinned) + } + toast.error(fetcher.data.error) + } + }, [fetcher.data]) // eslint-disable-line react-hooks/exhaustive-deps + const handlePinToggle = useCallback( - async (e: React.MouseEvent) => { + (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() if (isPinning) return - setIsPinning(true) // Optimistically update local state const newPinnedState = !localIsPinned setLocalIsPinned(newPinnedState) - try { - if (!newPinnedState) { - await unpinProject(id) - toast.success(t('dashboard.unpinned')) - } else { - await pinProject(id) - toast.success(t('dashboard.pinned')) - } - // Silently refetch projects in background - don't await - refetchProjects() - } catch (reason: any) { - // Revert on error - setLocalIsPinned(!newPinnedState) - console.error('[ERROR] Error while toggling pin:', reason) - toast.error(t('apiNotifications.somethingWentWrong')) - } finally { - setIsPinning(false) - } + const formData = new FormData() + formData.set('intent', newPinnedState ? 'pin-project' : 'unpin-project') + formData.set('projectId', id) + fetcher.submit(formData, { method: 'post' }) }, - [id, localIsPinned, isPinning, refetchProjects, t], + [id, localIsPinned, isPinning, fetcher], ) const badges = useMemo(() => { @@ -224,31 +244,16 @@ export const ProjectCard = ({ return list }, [t, active, isTransferring, isPublic, organisation, share, project.isAccessConfirmed, project.role, shareId]) - const onAccept = async () => { - try { - if (!shareId) { - throw new Error('Project share not found') - } - - await acceptProjectShare(shareId) - - mergeUser({ - sharedProjects: user?.sharedProjects?.map((item) => { - if (item.id === shareId) { - return { ...item, isAccessConfirmed: true } - } - - return item - }), - }) - - await refetchProjects() - - toast.success(t('apiNotifications.acceptInvitation')) - } catch (reason: any) { - console.error(`[ERROR] Error while accepting project invitation: ${reason}`) + const onAccept = () => { + if (!shareId) { toast.error(t('apiNotifications.acceptInvitationError')) + return } + + const formData = new FormData() + formData.set('intent', 'accept-project-share') + formData.set('shareId', shareId) + fetcher.submit(formData, { method: 'post' }) } const onElementClick = (e: React.MouseEvent) => { diff --git a/web/app/pages/Organisations/Settings/Projects.tsx b/web/app/pages/Organisations/Settings/Projects.tsx index d060b81df..311fbdc2a 100644 --- a/web/app/pages/Organisations/Settings/Projects.tsx +++ b/web/app/pages/Organisations/Settings/Projects.tsx @@ -8,7 +8,6 @@ import { useTranslation } from 'react-i18next' import { Link, useFetcher } from 'react-router' import { toast } from 'sonner' -import { getProjectsAvailableForOrganisation } from '~/api' import useDebounce from '~/hooks/useDebounce' import { DetailedOrganisation } from '~/lib/models/Organisation' import { Project } from '~/lib/models/Project' @@ -30,36 +29,34 @@ const PROJECT_LIST_PAGE_SIZE = 20 const SelectAProject = ({ onSelect }: SelectAProjectProps) => { const { t } = useTranslation('common') + const fetcher = useFetcher() const [currentPage, setCurrentPage] = useState(1) const [search, setSearch] = useState('') - const [isLoading, setIsLoading] = useState(false) const [projects, setProjects] = useState([]) const [total, setTotal] = useState(0) const debouncedSearch = useDebounce(search, 500) const pageAmount = Math.ceil(total / PROJECT_SELECT_PAGE_SIZE) + const isLoading = fetcher.state === 'submitting' || fetcher.state === 'loading' + // Fetch projects when page or search changes useEffect(() => { - const loadProjects = async () => { - setIsLoading(true) - try { - const { results, total: totalCount } = await getProjectsAvailableForOrganisation( - PROJECT_SELECT_PAGE_SIZE, - (currentPage - 1) * PROJECT_SELECT_PAGE_SIZE, - debouncedSearch, - ) - setProjects(results) - setTotal(totalCount) - } catch (reason) { - console.error('Failed to load projects:', reason) - } finally { - setIsLoading(false) - } - } + const formData = new FormData() + formData.set('intent', 'get-available-projects') + formData.set('take', String(PROJECT_SELECT_PAGE_SIZE)) + formData.set('skip', String((currentPage - 1) * PROJECT_SELECT_PAGE_SIZE)) + formData.set('search', debouncedSearch) + fetcher.submit(formData, { method: 'post' }) + }, [currentPage, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps - loadProjects() - }, [currentPage, debouncedSearch]) + // Update local state when fetcher returns data + useEffect(() => { + if (fetcher.data?.intent === 'get-available-projects' && fetcher.data?.availableProjects) { + setProjects(fetcher.data.availableProjects.results as Project[]) + setTotal(fetcher.data.availableProjects.total) + } + }, [fetcher.data]) useEffect(() => { setCurrentPage(1) diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index 6368be5d9..efd6be158 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next' import { Link, useNavigate, useFetcher, useLoaderData, useRevalidator } from 'react-router' import { toast } from 'sonner' -import { transferProject } from '~/api' import { TITLE_SUFFIX } from '~/lib/constants' import { DetailedOrganisation } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' @@ -24,8 +23,6 @@ import { Projects } from './Projects' const MAX_NAME_LENGTH = 50 -const DEFAULT_ORGANISATION_NAME = 'Untitled Organisation' - const OrganisationSettings = () => { const { t } = useTranslation('common') const loaderData = useLoaderData() @@ -41,8 +38,6 @@ const OrganisationSettings = () => { }>({}) const [beenSubmitted, setBeenSubmitted] = useState(false) const [showDelete, setShowDelete] = useState(false) - const [showTransfer, setShowTransfer] = useState(false) - const [transferEmail, setTransferEmail] = useState('') const organisation = loaderData?.organisation || null const error = loaderData?.error @@ -157,21 +152,6 @@ const OrganisationSettings = () => { } } - const onTransfer = async () => { - await transferProject(organisation?.id || '', transferEmail) - .then(() => { - toast.success(t('apiNotifications.transferRequestSent')) - navigate(routes.dashboard) - }) - .catch((reason) => { - toast.error(reason) - }) - .finally(() => { - setShowTransfer(false) - setTransferEmail('') - }) - } - const title = `${t('project.settings.settings')} ${form.name}` useEffect(() => { @@ -260,34 +240,6 @@ const OrganisationSettings = () => { isOpened={showDelete} isLoading={isDeleting} /> - { - setShowTransfer(false) - }} - submitText={t('project.settings.transfer')} - closeText={t('common.cancel')} - message={ -
-

{t('project.settings.transferTo')}

-

- {t('project.settings.transferHint', { - name: form.name || DEFAULT_ORGANISATION_NAME, - })} -

- setTransferEmail(e.target.value)} - /> -
- } - isOpened={showTransfer} - onSubmit={onTransfer} - />
) } diff --git a/web/app/routes/dashboard.tsx b/web/app/routes/dashboard.tsx index cfc30aa7d..4ebc35a52 100644 --- a/web/app/routes/dashboard.tsx +++ b/web/app/routes/dashboard.tsx @@ -28,6 +28,7 @@ export interface DashboardActionData { name?: string } project?: Project + isPinned?: boolean } export async function action({ request }: ActionFunctionArgs) { @@ -73,6 +74,69 @@ export async function action({ request }: ActionFunctionArgs) { ) } + case 'pin-project': { + const projectId = formData.get('projectId')?.toString() + + if (!projectId) { + return data({ intent, error: 'Project ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${projectId}/pin`, { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, isPinned: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'unpin-project': { + const projectId = formData.get('projectId')?.toString() + + if (!projectId) { + return data({ intent, error: 'Project ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${projectId}/pin`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, isPinned: false }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'accept-project-share': { + const shareId = formData.get('shareId')?.toString() + + if (!shareId) { + return data({ intent, error: 'Share ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `user/share/${shareId}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } diff --git a/web/app/routes/organisations.$id.tsx b/web/app/routes/organisations.$id.tsx index ad6375a3a..f0fc455e7 100644 --- a/web/app/routes/organisations.$id.tsx +++ b/web/app/routes/organisations.$id.tsx @@ -41,6 +41,17 @@ export async function loader({ request, params }: LoaderFunctionArgs) { ) } +interface AvailableProject { + id: string + name: string + uiHidden?: boolean +} + +interface AvailableProjectsResponse { + results: AvailableProject[] + total: number +} + export interface OrganisationSettingsActionData { success?: boolean intent?: string @@ -50,6 +61,7 @@ export interface OrganisationSettingsActionData { email?: string } organisation?: DetailedOrganisation + availableProjects?: AvailableProjectsResponse } export async function action({ request, params }: ActionFunctionArgs) { @@ -216,6 +228,26 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } + case 'get-available-projects': { + const take = parseInt(formData.get('take')?.toString() || '5', 10) + const skip = parseInt(formData.get('skip')?.toString() || '0', 10) + const search = formData.get('search')?.toString() || '' + + const result = await serverFetch( + request, + `project/available-for-organisation?take=${take}&skip=${skip}&search=${encodeURIComponent(search)}`, + ) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, availableProjects: result.data as AvailableProjectsResponse }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } From 31a47508fb7fa04732d7e2b61d2ebafb2573da5a Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 5 Jan 2026 00:27:46 +0000 Subject: [PATCH 11/57] migrate blog --- web/app/api/api.server.ts | 69 ++++ .../pages/Project/Settings/Emails/Emails.tsx | 131 ++++---- .../UserSettings/components/ProjectList.tsx | 72 ++-- web/app/routes/$.tsx | 8 +- web/app/routes/blog.$category.$slug.tsx | 8 +- web/app/routes/blog.$slug.tsx | 18 +- web/app/routes/blog._index.tsx | 14 +- web/app/routes/projects.settings.$id.tsx | 90 +++++ web/app/routes/user-settings.tsx | 42 +++ web/app/utils/getPosts.server.ts | 314 ++++++++++++++++++ 10 files changed, 647 insertions(+), 119 deletions(-) create mode 100644 web/app/utils/getPosts.server.ts diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 8ef5cfbed..2782b015d 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -382,3 +382,72 @@ export async function logoutUser( cookies: clearAuthCookies(), } } + +// ============================================================================ +// MARK: Blog API +// ============================================================================ + +interface BlogPost { + slug: string + title: string + date: string + hidden?: boolean + intro?: string +} + +interface BlogPostContent { + body: string + attributes?: { + title?: string + hidden?: boolean + intro?: string + date?: string + author?: string + twitter_handle?: string + standalone?: boolean + } +} + +export async function getBlogPosts(request: Request): Promise { + const result = await serverFetch(request, 'v1/blog', { skipAuth: true }) + + if (result.error || !result.data) { + return null + } + + return result.data +} + +export async function getBlogPost(request: Request, slug: string): Promise { + const result = await serverFetch(request, `v1/blog/${slug}`, { skipAuth: true }) + + if (result.error || !result.data) { + return null + } + + return result.data +} + +export async function getBlogPostWithCategory( + request: Request, + category: string, + slug: string, +): Promise { + const result = await serverFetch(request, `v1/blog/${category}/${slug}`, { skipAuth: true }) + + if (result.error || !result.data) { + return null + } + + return result.data +} + +export async function getSitemap(request: Request): Promise<(string | string[])[] | null> { + const result = await serverFetch<(string | string[])[]>(request, 'v1/blog/sitemap', { skipAuth: true }) + + if (result.error || !result.data) { + return null + } + + return result.data +} diff --git a/web/app/pages/Project/Settings/Emails/Emails.tsx b/web/app/pages/Project/Settings/Emails/Emails.tsx index 3757dd6c3..d8ff1f31b 100644 --- a/web/app/pages/Project/Settings/Emails/Emails.tsx +++ b/web/app/pages/Project/Settings/Emails/Emails.tsx @@ -9,12 +9,13 @@ import _toLower from 'lodash/toLower' import { MailPlusIcon, Trash2Icon } from 'lucide-react' import React, { useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher, useParams } from 'react-router' import { toast } from 'sonner' -import { addSubscriber, removeSubscriber, getSubscribers, updateSubscriber } from '~/api' import useOnClickOutside from '~/hooks/useOnClickOutside' import { reportFrequencyForEmailsOptions } from '~/lib/constants' import { Subscriber } from '~/lib/models/Subscriber' +import { ProjectSettingsActionData } from '~/routes/projects.settings.$id' import { Badge } from '~/ui/Badge' import Button from '~/ui/Button' import Input from '~/ui/Input' @@ -141,29 +142,33 @@ const EmailList = ({ data, onRemove, setEmails }: EmailListProps) => { t, i18n: { language }, } = useTranslation('common') + const { id: projectId } = useParams() + const fetcher = useFetcher() const [open, setOpen] = useState(false) const openRef = useRef(null) useOnClickOutside(openRef, () => setOpen(false)) - const { id, addedAt, isConfirmed, projectId, email, reportFrequency } = data || {} - - const changeRole = async (reportType: { value: string; label: string }) => { - try { - const results = await updateSubscriber(projectId, id, { reportFrequency: reportType.value }) - setEmails((prev) => { - const newEmails = _map(prev, (item) => { - if (item.id === results.id) { - return results - } - return item - }) - return newEmails - }) - toast.success(t('apiNotifications.updatedPeriodEmailReports')) - } catch (reason: any) { - console.error(`[ERROR] Error while updating user's role: ${reason}`) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.updatedPeriodEmailReportsError')) + const { id, addedAt, isConfirmed, email, reportFrequency } = data || {} + + useEffect(() => { + if (fetcher.data?.intent === 'update-subscriber') { + if (fetcher.data.success && fetcher.data.subscriber) { + setEmails((prev) => + _map(prev, (item) => (item.id === fetcher.data!.subscriber!.id ? fetcher.data!.subscriber! : item)), + ) + toast.success(t('apiNotifications.updatedPeriodEmailReports')) + } else if (fetcher.data.error) { + toast.error( + typeof fetcher.data.error === 'string' ? fetcher.data.error : t('apiNotifications.updatedPeriodEmailReportsError'), + ) + } } + }, [fetcher.data, setEmails, t]) + const changeRole = (reportType: { value: string; label: string }) => { + fetcher.submit( + { intent: 'update-subscriber', subscriberId: id, reportFrequency: reportType.value }, + { method: 'post', action: `/projects/settings/${projectId}` }, + ) setOpen(false) } @@ -254,6 +259,9 @@ const NoSubscribers = () => { const Emails = ({ projectId }: { projectId: string }) => { const [showModal, setShowModal] = useState(false) const { t } = useTranslation('common') + const fetcher = useFetcher() + const loadFetcher = useFetcher() + const removeFetcher = useFetcher() const [form, setForm] = useState<{ email: string reportFrequency: string @@ -276,27 +284,49 @@ const Emails = ({ projectId }: { projectId: string }) => { }) const [showDeleteModal, setShowDeleteModal] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) const [emailToRemove, setEmailToRemove] = useState(null) - const getSubcribersAsync = async () => { - try { - const { subscribers, count } = await getSubscribers(projectId, pagination.page - 1, pagination.limit) - setPagination((oldPaggination) => ({ - ...oldPaggination, - count, - })) - setEmails(subscribers) - } catch (reason) { - console.error(`[ERROR] Error while getting subscribers: ${reason}`) - } finally { + const isDeleting = removeFetcher.state !== 'idle' + + useEffect(() => { + loadFetcher.submit( + { intent: 'get-subscribers', offset: String(pagination.page - 1), limit: String(pagination.limit) }, + { method: 'post', action: `/projects/settings/${projectId}` }, + ) + }, []) // eslint-disable-line + + useEffect(() => { + if (loadFetcher.data?.intent === 'get-subscribers') { + if (loadFetcher.data.success) { + setEmails(loadFetcher.data.subscribers || []) + setPagination((old) => ({ ...old, count: loadFetcher.data!.subscribersCount || 0 })) + } setLoading(false) } - } + }, [loadFetcher.data]) useEffect(() => { - getSubcribersAsync() - }, []) // eslint-disable-line + if (fetcher.data?.intent === 'add-subscriber') { + if (fetcher.data.success && fetcher.data.subscriber) { + setEmails((prev) => [...prev, fetcher.data!.subscriber!]) + toast.success(t('apiNotifications.userInvited')) + } else if (fetcher.data.error) { + toast.error(typeof fetcher.data.error === 'string' ? fetcher.data.error : t('apiNotifications.userInviteError')) + } + } + }, [fetcher.data, t]) + + useEffect(() => { + if (removeFetcher.data?.intent === 'remove-subscriber') { + if (removeFetcher.data.success) { + setEmails((prev) => _filter(prev, (s) => s.id !== emailToRemove?.id)) + } else if (removeFetcher.data.error) { + toast.error( + typeof removeFetcher.data.error === 'string' ? removeFetcher.data.error : t('apiNotifications.somethingWentWrong'), + ) + } + } + }, [removeFetcher.data, emailToRemove, t]) const validate = () => { const allErrors: { @@ -329,21 +359,16 @@ const Emails = ({ projectId }: { projectId: string }) => { })) } - const onSubmit = async () => { + const onSubmit = () => { setShowModal(false) setErrors({}) setValidated(false) - try { - const results = await addSubscriber(projectId, { reportFrequency: form.reportFrequency, email: form.email }) - setEmails([...emails, results]) - toast.success(t('apiNotifications.userInvited')) - } catch (reason) { - console.error(`[ERROR] Error while inviting a user: ${reason}`) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.userInviteError')) - } + fetcher.submit( + { intent: 'add-subscriber', email: form.email, reportFrequency: form.reportFrequency }, + { method: 'post', action: `/projects/settings/${projectId}` }, + ) - // a timeout is needed to prevent the flicker of data fields in the modal when closing setTimeout(() => setForm({ email: '', reportFrequency: '' }), 300) } @@ -366,23 +391,15 @@ const Emails = ({ projectId }: { projectId: string }) => { setErrors({}) } - const onRemove = async (emailId: string) => { + const onRemove = (emailId: string) => { if (isDeleting) { return } - setIsDeleting(true) - - try { - await removeSubscriber(projectId, emailId) - const results = _filter(emails, (s) => s.id !== emailId) - setEmails(results) - } catch (reason: any) { - console.error(`[ERROR] Error while deleting a email: ${reason}`) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.somethingWentWrong')) - } finally { - setIsDeleting(false) - } + removeFetcher.submit( + { intent: 'remove-subscriber', subscriberId: emailId }, + { method: 'post', action: `/projects/settings/${projectId}` }, + ) } return ( diff --git a/web/app/pages/UserSettings/components/ProjectList.tsx b/web/app/pages/UserSettings/components/ProjectList.tsx index 3d43c724f..784165033 100644 --- a/web/app/pages/UserSettings/components/ProjectList.tsx +++ b/web/app/pages/UserSettings/components/ProjectList.tsx @@ -1,13 +1,14 @@ import dayjs from 'dayjs' import _filter from 'lodash/filter' import _map from 'lodash/map' -import { memo, useState } from 'react' +import { memo, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { rejectProjectShare, acceptProjectShare } from '~/api' import { SharedProject } from '~/lib/models/SharedProject' import { useAuth } from '~/providers/AuthProvider' +import { UserSettingsActionData } from '~/routes/user-settings' import Button from '~/ui/Button' import Modal from '~/ui/Modal' @@ -21,41 +22,48 @@ const ProjectList = ({ item }: ProjectListProps) => { i18n: { language }, } = useTranslation('common') const { user, mergeUser } = useAuth() + const fetcher = useFetcher() const [showDeleteModal, setShowDeleteModal] = useState(false) const { created, confirmed, id, role, project } = item - const onQuit = async () => { - try { - await rejectProjectShare(item.id) - mergeUser({ - sharedProjects: _filter(user?.sharedProjects, (share) => share.id !== item.id), - }) - toast.success(t('apiNotifications.quitProject')) - } catch (reason) { - console.error(`[ERROR] Error while quitting project: ${reason}`) - toast.error(t('apiNotifications.quitProjectError')) + const isPending = fetcher.state !== 'idle' + + useEffect(() => { + if (fetcher.data?.intent === 'reject-project-share') { + if (fetcher.data.success) { + mergeUser({ + sharedProjects: _filter(user?.sharedProjects, (share) => share.id !== item.id), + }) + toast.success(t('apiNotifications.quitProject')) + } else if (fetcher.data.error) { + toast.error(t('apiNotifications.quitProjectError')) + } } - } - const onAccept = async () => { - try { - await acceptProjectShare(id) + if (fetcher.data?.intent === 'accept-project-share') { + if (fetcher.data.success) { + mergeUser({ + sharedProjects: _map(user?.sharedProjects, (share) => { + if (share.id === id) { + return { ...share, confirmed: true } + } + return share + }), + }) + toast.success(t('apiNotifications.acceptInvitation')) + } else if (fetcher.data.error) { + toast.error(t('apiNotifications.acceptInvitationError')) + } + } + }, [fetcher.data, id, item.id, mergeUser, t, user?.sharedProjects]) - mergeUser({ - sharedProjects: _map(user?.sharedProjects, (item) => { - if (item.id === id) { - return { ...item, confirmed: true } - } - return item - }), - }) + const onQuit = () => { + fetcher.submit({ intent: 'reject-project-share', shareId: item.id }, { method: 'post', action: '/user-settings' }) + } - toast.success(t('apiNotifications.acceptInvitation')) - } catch (reason) { - console.error(`[ERROR] Error while accepting project invitation: ${reason}`) - toast.error(t('apiNotifications.acceptInvitationError')) - } + const onAccept = () => { + fetcher.submit({ intent: 'accept-project-share', shareId: id }, { method: 'post', action: '/user-settings' }) } return ( @@ -71,15 +79,15 @@ const ProjectList = ({ item }: ProjectListProps) => { {confirmed ? ( - ) : ( <> - - diff --git a/web/app/routes/$.tsx b/web/app/routes/$.tsx index 8799825cf..d2096681f 100644 --- a/web/app/routes/$.tsx +++ b/web/app/routes/$.tsx @@ -1,11 +1,11 @@ -import type { LoaderFunction, MetaFunction } from 'react-router' +import type { LoaderFunctionArgs, MetaFunction } from 'react-router' import { data, useLoaderData } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' import { getOgImageUrl } from '~/lib/constants' import Post from '~/pages/Blog/Post' import NotFound from '~/pages/NotFound' -import { getPost } from '~/utils/getPosts' +import { getPost } from '~/utils/getPosts.server' import { getDescription, getPreviewImage, getTitle } from '~/utils/seo' export const meta: MetaFunction = (loaderData: any) => { @@ -26,14 +26,14 @@ export const sitemap: SitemapFunction = () => ({ exclude: true, }) -export const loader: LoaderFunction = async ({ params }) => { +export async function loader({ request, params }: LoaderFunctionArgs) { const catchAllPath = params['*'] if (!catchAllPath) { return data(null, { status: 404 }) } - const post = await getPost(catchAllPath, undefined, true) + const post = await getPost(request, catchAllPath, undefined, true) if (post) { return post diff --git a/web/app/routes/blog.$category.$slug.tsx b/web/app/routes/blog.$category.$slug.tsx index 54384fcdc..024061dcf 100644 --- a/web/app/routes/blog.$category.$slug.tsx +++ b/web/app/routes/blog.$category.$slug.tsx @@ -1,9 +1,9 @@ -import type { LoaderFunction, MetaFunction } from 'react-router' +import type { LoaderFunctionArgs, MetaFunction } from 'react-router' import { redirect, data } from 'react-router' import { isSelfhosted, getOgImageUrl, isDisableMarketingPages } from '~/lib/constants' import Post from '~/pages/Blog/Post' -import { getPost } from '~/utils/getPosts' +import { getPost } from '~/utils/getPosts.server' import { getDescription, getPreviewImage, getTitle } from '~/utils/seo' export const meta: MetaFunction = (loaderData: any) => { @@ -16,7 +16,7 @@ export const meta: MetaFunction = (loaderData: any) => { ] } -export const loader: LoaderFunction = async ({ params }) => { +export async function loader({ request, params }: LoaderFunctionArgs) { if (isSelfhosted || isDisableMarketingPages) { return redirect('/dashboard', 302) } @@ -29,7 +29,7 @@ export const loader: LoaderFunction = async ({ params }) => { }) } - const post = await getPost(slug, category) + const post = await getPost(request, slug, category) if (!post) { return data(null, { diff --git a/web/app/routes/blog.$slug.tsx b/web/app/routes/blog.$slug.tsx index 5e2c51f1b..20aee37c4 100644 --- a/web/app/routes/blog.$slug.tsx +++ b/web/app/routes/blog.$slug.tsx @@ -2,13 +2,13 @@ import _isString from 'lodash/isString' import _last from 'lodash/last' import _map from 'lodash/map' import { redirect, data } from 'react-router' -import type { LoaderFunction, MetaFunction } from 'react-router' +import type { LoaderFunctionArgs, MetaFunction } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' -import { getSitemap } from '~/api' +import { getSitemap as getSitemapClient } from '~/api' import { isSelfhosted, getOgImageUrl, isDisableMarketingPages } from '~/lib/constants' import Post from '~/pages/Blog/Post' -import { getPost, getSlugFromFilename, getDateFromFilename } from '~/utils/getPosts' +import { getPost, getSlugFromFilename, getDateFromFilename } from '~/utils/getPosts.server' import { getDescription, getPreviewImage, getTitle } from '~/utils/seo' export const meta: MetaFunction = (loaderData: any) => { @@ -23,7 +23,7 @@ export const meta: MetaFunction = (loaderData: any) => { // @ts-expect-error SitemapFunction does not support async, investigate it later export const sitemap: SitemapFunction = async () => { - const files = await getSitemap() + const files = await getSitemapClient() if (isSelfhosted || isDisableMarketingPages) { return { @@ -36,7 +36,6 @@ export const sitemap: SitemapFunction = async () => { let date: string if (_isString(file)) { - // Standalone post in root - just the filename (e.g., "2025-05-31-data-policy") handle = getSlugFromFilename(file) date = getDateFromFilename(file) } else { @@ -44,18 +43,13 @@ export const sitemap: SitemapFunction = async () => { const firstElement = file[0] if (firstElement === 'blog') { - // Blog posts: ["blog", "filename"] or ["blog", "category", "filename"] if (file.length === 2) { - // ["blog", "filename"] - blog post in root handle = `blog/${getSlugFromFilename(_file)}` } else { - // ["blog", "category", "filename"] - blog post in subdirectory const category = file[1] handle = `blog/${category}/${getSlugFromFilename(_file)}` } } else { - // Standalone post in category folder: ["category", "filename"] - // e.g., ["comparison", "2025-11-26-plausible"] -> /comparison/plausible handle = `${firstElement}/${getSlugFromFilename(_file)}` } @@ -70,7 +64,7 @@ export const sitemap: SitemapFunction = async () => { }) } -export const loader: LoaderFunction = async ({ params }) => { +export async function loader({ request, params }: LoaderFunctionArgs) { if (isSelfhosted || isDisableMarketingPages) { return redirect('/dashboard', 302) } @@ -81,7 +75,7 @@ export const loader: LoaderFunction = async ({ params }) => { }) } - const post = await getPost(params.slug) + const post = await getPost(request, params.slug) if (!post) { return data(null, { diff --git a/web/app/routes/blog._index.tsx b/web/app/routes/blog._index.tsx index 1b74702f6..5393e7834 100644 --- a/web/app/routes/blog._index.tsx +++ b/web/app/routes/blog._index.tsx @@ -2,25 +2,19 @@ import _filter from 'lodash/filter' import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' import { ChevronRightIcon } from 'lucide-react' -import type { LoaderFunction, MetaFunction } from 'react-router' +import type { LoaderFunctionArgs, MetaFunction } from 'react-router' import { redirect, Link, useLoaderData } from 'react-router' -import { getBlogPosts } from '~/api' +import { getBlogPosts } from '~/api/api.server' import { isDisableMarketingPages, isSelfhosted } from '~/lib/constants' import { getTitle } from '~/utils/seo' -export const loader: LoaderFunction = async () => { +export async function loader({ request }: LoaderFunctionArgs) { if (isSelfhosted || isDisableMarketingPages) { return redirect('/dashboard', 302) } - const data = await getBlogPosts() - .then((data) => { - return data - }) - .catch((error) => { - console.error(error) - }) + const data = await getBlogPosts(request) if (!data || _isEmpty(data)) { return null diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 388ab40b6..3b80f3b0c 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -3,6 +3,7 @@ import { data } from 'react-router' import { serverFetch } from '~/api/api.server' import { Project } from '~/lib/models/Project' +import { Subscriber } from '~/lib/models/Subscriber' import ProjectSettings from '~/pages/Project/Settings' import { redirectIfNotAuthenticated, createHeadersWithCookies } from '~/utils/session.server' @@ -23,6 +24,9 @@ export interface ProjectSettingsActionData { transferEmail?: string } project?: Project + subscriber?: Subscriber + subscribers?: Subscriber[] + subscribersCount?: number } export async function action({ request, params }: ActionFunctionArgs) { @@ -210,6 +214,92 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } + case 'get-subscribers': { + const offset = Number(formData.get('offset') || 0) + const limit = Number(formData.get('limit') || 10) + + const result = await serverFetch<{ subscribers: Subscriber[]; count: number }>( + request, + `project/${id}/subscribers?offset=${offset}&limit=${limit}`, + ) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, subscribers: result.data?.subscribers, subscribersCount: result.data?.count }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'add-subscriber': { + const email = formData.get('email')?.toString() + const reportFrequency = formData.get('reportFrequency')?.toString() + + if (!email) { + return data({ intent, error: 'Email is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${id}/subscribers`, { + method: 'POST', + body: { email, reportFrequency }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, subscriber: result.data as Subscriber }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-subscriber': { + const subscriberId = formData.get('subscriberId')?.toString() + const reportFrequency = formData.get('reportFrequency')?.toString() + + if (!subscriberId) { + return data({ intent, error: 'Subscriber ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${id}/subscribers/${subscriberId}`, { + method: 'PUT', + body: { reportFrequency }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, subscriber: result.data as Subscriber }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'remove-subscriber': { + const subscriberId = formData.get('subscriberId')?.toString() + + if (!subscriberId) { + return data({ intent, error: 'Subscriber ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${id}/subscribers/${subscriberId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } diff --git a/web/app/routes/user-settings.tsx b/web/app/routes/user-settings.tsx index 27dce54b5..d6fd9bf6d 100644 --- a/web/app/routes/user-settings.tsx +++ b/web/app/routes/user-settings.tsx @@ -252,6 +252,48 @@ export async function action({ request }: ActionFunctionArgs) { ) } + case 'accept-project-share': { + const shareId = formData.get('shareId')?.toString() + + if (!shareId) { + return data({ intent, error: 'Share ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `user/share/${shareId}`, { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'reject-project-share': { + const shareId = formData.get('shareId')?.toString() + + if (!shareId) { + return data({ intent, error: 'Share ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `user/share/${shareId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } diff --git a/web/app/utils/getPosts.server.ts b/web/app/utils/getPosts.server.ts new file mode 100644 index 000000000..92cb3d321 --- /dev/null +++ b/web/app/utils/getPosts.server.ts @@ -0,0 +1,314 @@ +import _isEmpty from 'lodash/isEmpty' +import { marked, type Tokens } from 'marked' +import sanitizeHtml from 'sanitize-html' + +import { getBlogPost, getBlogPostWithCategory } from '~/api/api.server' + +import { renderTimeToSwitchCta, renderDitchGoogleCta } from './renderCtaHtml' +import { extractTableOfContents, ensureHeaderIds, generateSlug, renderTocAsHtml } from './toc' + +const renderer = new marked.Renderer() + +renderer.link = ({ href, text }: Tokens.Link) => { + let url: URL + + try { + url = new URL(href, 'https://swetrix.com') + } catch { + return text + } + + if (url.hostname !== 'swetrix.com') { + url.searchParams.set('utm_source', 'swetrix.com') + } + + return `${text}` +} + +renderer.heading = ({ text, depth }: Tokens.Heading) => { + const id = generateSlug(text) + return `${text}` +} + +function parseInlineMarkdown(text: string): string { + return marked.parseInline(text) as string +} + +function isSectionHeaderRow(row: Tokens.TableCell[]): boolean { + const firstCellText = row[0]?.text?.trim() || '' + const isBold = firstCellText.startsWith('**') && firstCellText.endsWith('**') + const otherCellsEmpty = row.slice(1).every((cell) => !cell.text?.trim()) + return isBold && otherCellsEmpty +} + +renderer.table = ({ header, rows }: Tokens.Table) => { + const headerCells = header + .map( + (cell) => + `${parseInlineMarkdown(cell.text)}`, + ) + .join('') + + const bodyRows = rows + .map((row) => { + const isHeader = isSectionHeaderRow(row) + + if (isHeader) { + const cellContent = parseInlineMarkdown(row[0].text) + return `${cellContent}` + } + + return `${row + .map((cell) => { + return `${parseInlineMarkdown(cell.text)}` + }) + .join('')}` + }) + .join('') + + return `
+ + ${headerCells} + ${bodyRows} +
+
` +} + +const CHECK_ICON_SVG = `` + +const CROSS_ICON_SVG = `` + +const WARNING_ICON_SVG = `` + +const SWETRIX_LOGO_HTML = `Swetrix` + +const ALERT_CONFIGS = { + NOTE: { + icon: ``, + bgClass: 'bg-blue-50 dark:bg-blue-950/50', + borderClass: 'border-blue-400 dark:border-blue-500', + iconClass: 'text-blue-500 dark:text-blue-400', + titleClass: 'text-blue-800 dark:text-blue-200', + contentClass: 'text-blue-700 dark:text-blue-300', + title: 'Note', + }, + TIP: { + icon: ``, + bgClass: 'bg-emerald-50 dark:bg-emerald-950/50', + borderClass: 'border-emerald-400 dark:border-emerald-500', + iconClass: 'text-emerald-500 dark:text-emerald-400', + titleClass: 'text-emerald-800 dark:text-emerald-200', + contentClass: 'text-emerald-700 dark:text-emerald-300', + title: 'Tip', + }, + IMPORTANT: { + icon: ``, + bgClass: 'bg-purple-50 dark:bg-purple-950/50', + borderClass: 'border-purple-400 dark:border-purple-500', + iconClass: 'text-purple-500 dark:text-purple-400', + titleClass: 'text-purple-800 dark:text-purple-200', + contentClass: 'text-purple-700 dark:text-purple-300', + title: 'Important', + }, + WARNING: { + icon: ``, + bgClass: 'bg-amber-50 dark:bg-amber-950/50', + borderClass: 'border-amber-400 dark:border-amber-500', + iconClass: 'text-amber-500 dark:text-amber-400', + titleClass: 'text-amber-800 dark:text-amber-200', + contentClass: 'text-amber-700 dark:text-amber-300', + title: 'Warning', + }, + CAUTION: { + icon: ``, + bgClass: 'bg-red-50 dark:bg-red-950/50', + borderClass: 'border-red-400 dark:border-red-500', + iconClass: 'text-red-500 dark:text-red-400', + titleClass: 'text-red-800 dark:text-red-200', + contentClass: 'text-red-700 dark:text-red-300', + title: 'Caution', + }, +} as const + +type AlertType = keyof typeof ALERT_CONFIGS + +renderer.blockquote = ({ text }: Tokens.Blockquote) => { + const alertMatch = text.match(/^\s*(?:

)?\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:|\n)?(?:<\/p>)?\s*/i) + + if (alertMatch) { + const alertType = alertMatch[1].toUpperCase() as AlertType + const config = ALERT_CONFIGS[alertType] + const rawContent = text.replace(alertMatch[0], '').trim() + + const content = marked(rawContent, { renderer }) as string + + return `

+
+ ${config.icon} +
+

${config.title}

+
${content}
+
+
+
` + } + + const renderedText = marked(text, { renderer }) as string + return `
${renderedText}
` +} + +function replaceEmojiWithIcons(html: string): string { + return html.replace(/✅/g, CHECK_ICON_SVG).replace(/❌/g, CROSS_ICON_SVG).replace(/⚠️/g, WARNING_ICON_SVG) +} + +function replaceSwetrixLogo(html: string): string { + return html.replace(/::SWETRIX_LOGO::/g, SWETRIX_LOGO_HTML) +} + +export const getSlugFromFilename = (filename: string) => filename.substring(11) +export const getDateFromFilename = (filename: string) => filename.substring(0, 10) + +interface GetPost { + slug: string + title?: string + html: string + hidden?: boolean + standalone?: boolean + intro?: string + date?: string + author?: string + twitter_handle?: string +} + +export async function getPost( + request: Request, + slug: string, + category?: string, + tryStandalone?: boolean, +): Promise { + let post: Awaited> = null + + try { + if (category) { + post = await getBlogPostWithCategory(request, category, slug) + } else { + post = await getBlogPost(request, slug) + } + } catch { + return null + } + + if (!post || _isEmpty(post)) { + return null + } + + if (!!tryStandalone !== !!post.attributes?.standalone) { + return null + } + + const tocPlaceholder = '::TABLE_OF_CONTENTS::' + const ctaTimeToSwitchPlaceholder = '::CTA:TIME_TO_SWITCH::' + const ctaDitchGooglePlaceholder = '::CTA:TIME_TO_DITCH_GOOGLE::' + const hasTocPlaceholder = post.body.includes(tocPlaceholder) + const hasCtaTimeToSwitch = post.body.includes(ctaTimeToSwitchPlaceholder) + const hasCtaDitchGoogle = post.body.includes(ctaDitchGooglePlaceholder) + + let html = marked(post.body, { renderer }) as string + + html = ensureHeaderIds(html) + html = replaceEmojiWithIcons(html) + html = replaceSwetrixLogo(html) + + const toc = extractTableOfContents(html) + + if (hasTocPlaceholder && toc.length > 0) { + const tocHtml = renderTocAsHtml(toc) + html = html.replace(new RegExp(tocPlaceholder, 'gi'), tocHtml) + } + + if (hasCtaTimeToSwitch) { + const ctaHtml = renderTimeToSwitchCta() + html = html.replace(new RegExp(ctaTimeToSwitchPlaceholder, 'gi'), ctaHtml) + } + + if (hasCtaDitchGoogle) { + const ctaHtml = renderDitchGoogleCta() + html = html.replace(new RegExp(ctaDitchGooglePlaceholder, 'gi'), ctaHtml) + } + + html = sanitizeHtml(html, { + allowedTags: [ + 'p', + 'br', + 'hr', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'strong', + 'em', + 's', + 'del', + 'blockquote', + 'pre', + 'code', + 'ul', + 'ol', + 'li', + 'a', + 'img', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'div', + 'span', + 'svg', + 'path', + ], + allowedAttributes: { + '*': ['class', 'id', 'role', 'aria-label', 'aria-hidden'], + a: ['href', 'target', 'rel', 'referrerpolicy', 'title'], + img: ['src', 'alt', 'title', 'width', 'height', 'loading'], + th: ['colspan', 'rowspan'], + td: ['colspan', 'rowspan'], + svg: [ + 'class', + 'xmlns', + 'viewbox', + 'width', + 'height', + 'fill', + 'stroke', + 'stroke-width', + 'stroke-linecap', + 'stroke-linejoin', + 'role', + 'aria-label', + ], + path: ['d', 'fill', 'fill-rule', 'clip-rule'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'tel'], + allowedSchemesByTag: { + img: ['http', 'https'], + }, + allowProtocolRelative: false, + allowedStyles: {}, + }) + + return { + slug, + title: post.attributes?.title, + html, + hidden: post.attributes?.hidden, + intro: post.attributes?.intro, + date: post.attributes?.date, + author: post.attributes?.author, + twitter_handle: post.attributes?.twitter_handle, + standalone: post.attributes?.standalone, + } +} From c22d5b0df4c112487a5511dcd3ab8744da95b371 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 5 Jan 2026 00:42:18 +0000 Subject: [PATCH 12/57] fixes --- web/app/pages/Project/Settings/Emails/Emails.tsx | 2 ++ web/app/pages/Project/Settings/People/People.tsx | 2 ++ web/app/routes/blog.$slug.tsx | 3 ++- web/app/utils/blog.ts | 3 +++ web/app/utils/getPosts.server.ts | 3 --- 5 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 web/app/utils/blog.ts diff --git a/web/app/pages/Project/Settings/Emails/Emails.tsx b/web/app/pages/Project/Settings/Emails/Emails.tsx index d8ff1f31b..548fbb7d3 100644 --- a/web/app/pages/Project/Settings/Emails/Emails.tsx +++ b/web/app/pages/Project/Settings/Emails/Emails.tsx @@ -361,6 +361,7 @@ const Emails = ({ projectId }: { projectId: string }) => { const onSubmit = () => { setShowModal(false) + setBeenSubmitted(false) setErrors({}) setValidated(false) @@ -386,6 +387,7 @@ const Emails = ({ projectId }: { projectId: string }) => { const closeModal = () => { setShowModal(false) + setBeenSubmitted(false) // a timeout is needed to prevent the flicker of data fields in the modal when closing setTimeout(() => setForm({ email: '', reportFrequency: '' }), 300) setErrors({}) diff --git a/web/app/pages/Project/Settings/People/People.tsx b/web/app/pages/Project/Settings/People/People.tsx index 13fa5a8b5..6baeb40ab 100644 --- a/web/app/pages/Project/Settings/People/People.tsx +++ b/web/app/pages/Project/Settings/People/People.tsx @@ -209,6 +209,7 @@ const People = ({ project, reloadProject }: PeopleProps) => { const onSubmit = async () => { setShowModal(false) + setBeenSubmitted(false) setErrors({}) setValidated(false) @@ -239,6 +240,7 @@ const People = ({ project, reloadProject }: PeopleProps) => { const closeModal = () => { setShowModal(false) + setBeenSubmitted(false) // a timeout is needed to prevent the flicker of data fields in the modal when closing setTimeout(() => setForm({ email: '', role: 'viewer' }), 300) setErrors({}) diff --git a/web/app/routes/blog.$slug.tsx b/web/app/routes/blog.$slug.tsx index 20aee37c4..efccb2675 100644 --- a/web/app/routes/blog.$slug.tsx +++ b/web/app/routes/blog.$slug.tsx @@ -8,7 +8,8 @@ import type { SitemapFunction } from 'remix-sitemap' import { getSitemap as getSitemapClient } from '~/api' import { isSelfhosted, getOgImageUrl, isDisableMarketingPages } from '~/lib/constants' import Post from '~/pages/Blog/Post' -import { getPost, getSlugFromFilename, getDateFromFilename } from '~/utils/getPosts.server' +import { getSlugFromFilename, getDateFromFilename } from '~/utils/blog' +import { getPost } from '~/utils/getPosts.server' import { getDescription, getPreviewImage, getTitle } from '~/utils/seo' export const meta: MetaFunction = (loaderData: any) => { diff --git a/web/app/utils/blog.ts b/web/app/utils/blog.ts new file mode 100644 index 000000000..c70e45901 --- /dev/null +++ b/web/app/utils/blog.ts @@ -0,0 +1,3 @@ +// Removes first 10 characters from the string (i.e. 2023-10-07-) +export const getSlugFromFilename = (filename: string) => filename.substring(11) +export const getDateFromFilename = (filename: string) => filename.substring(0, 10) diff --git a/web/app/utils/getPosts.server.ts b/web/app/utils/getPosts.server.ts index 92cb3d321..e9ed3f89f 100644 --- a/web/app/utils/getPosts.server.ts +++ b/web/app/utils/getPosts.server.ts @@ -165,9 +165,6 @@ function replaceSwetrixLogo(html: string): string { return html.replace(/::SWETRIX_LOGO::/g, SWETRIX_LOGO_HTML) } -export const getSlugFromFilename = (filename: string) => filename.substring(11) -export const getDateFromFilename = (filename: string) => filename.substring(0, 10) - interface GetPost { slug: string title?: string From bf75e7535b71279876052a096c4d4b3cffc19a47 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 5 Jan 2026 00:51:15 +0000 Subject: [PATCH 13/57] move revenue stuff to server actions --- web/app/api/index.ts | 169 ------------------ .../pages/Project/Settings/People/People.tsx | 112 ++++++------ .../Project/Settings/ProjectSettings.tsx | 2 +- .../pages/Project/Settings/tabs/Revenue.tsx | 146 ++++++++------- web/app/routes/projects.settings.$id.tsx | 150 ++++++++++++++++ 5 files changed, 294 insertions(+), 285 deletions(-) diff --git a/web/app/api/index.ts b/web/app/api/index.ts index a791f68df..6b19a1f87 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -20,7 +20,6 @@ import { ProfileDetails, } from '~/lib/models/Project' import { Stats } from '~/lib/models/Stats' -import { Subscriber } from '~/lib/models/Subscriber' import { User } from '~/lib/models/User' import { Filter, ProjectViewCustomEvent } from '~/pages/Project/View/interfaces/traffic' import { getAccessToken, setAccessToken } from '~/utils/accessToken' @@ -719,57 +718,6 @@ export const getGeneralStats = () => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const shareProject = ( - pid: string, - data: { - email: string - role: string - }, -) => - api - .post(`/project/${pid}/share`, data) - .then((response): Project => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const deleteShareProjectUsers = (pid: string, userId: string) => - api - .delete(`/project/${pid}/${userId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const rejectProjectShare = (actionId: string) => - api - .delete(`/user/share/${actionId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const acceptProjectShare = (actionId: string) => - api - .get(`/user/share/${actionId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const changeShareRole = ( - id: string, - data: { - role: string - }, -) => - api - .put(`project/share/${id}`, data) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export interface GetLiveVisitorsInfo { psid: string dv: string @@ -1296,20 +1244,6 @@ export const getExperimentResults = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const addSubscriber = ( - id: string, - data: { - email: string - reportFrequency: string - }, -) => - api - .post(`project/${id}/subscribers`, data) - .then((response): Subscriber => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const addFunnel = (pid: string, name: string, steps: string[]) => api .post('project/funnel', { pid, name, steps }) @@ -1370,43 +1304,6 @@ export const deleteAnnotation = (id: string, pid: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getSubscribers = (id: string, offset: number, limit: number) => - api - .get(`project/${id}/subscribers?offset=${offset}&limit=${limit}`) - .then( - ( - response, - ): { - subscribers: Subscriber[] - count: number - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const updateSubscriber = ( - id: string, - subscriberId: string, - data: { - reportFrequency: string - }, -) => - api - .patch(`project/${id}/subscribers/${subscriberId}`, data) - .then((response): Subscriber => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const removeSubscriber = (id: string, subscriberId: string) => - api - .delete(`project/${id}/subscribers/${subscriberId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProjectDataCustomEvents = ( pid: string, tb = 'hour', @@ -1626,38 +1523,6 @@ export const changeSubscriptionPlan = (planId: number) => throw error }) -export const getBlogPosts = () => - api - .get('v1/blog') - .then((response) => response.data) - .catch((error) => { - throw error - }) - -export const getBlogPost = (slug: string) => - api - .get(`v1/blog/${slug}`) - .then((response) => response.data) - .catch((error) => { - throw error - }) - -export const getSitemap = () => - api - .get('v1/blog/sitemap') - .then((response) => response.data) - .catch((error) => { - throw error - }) - -export const getBlogPostWithCategory = (category: string, slug: string) => - api - .get(`v1/blog/${category}/${slug}`) - .then((response) => response.data) - .catch((error) => { - throw error - }) - export const updateErrorStatus = (pid: string, status: 'resolved' | 'active', eid?: string, eids?: string[]) => api .patch('log/error-status', { pid, eid, eids, status }) @@ -1954,40 +1819,6 @@ export const getRevenueStatus = async (pid: string): Promise => { }) } -export type RevenueProvider = 'stripe' | 'paddle' - -export const connectRevenue = async ( - pid: string, - provider: RevenueProvider, - apiKey: string, - currency: string = 'USD', -): Promise<{ success: boolean }> => { - return api - .post(`project/${pid}/revenue/connect`, { provider, apiKey, currency }) - .then((response): { success: boolean } => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -export const disconnectRevenue = async (pid: string): Promise => { - return api - .delete(`project/${pid}/revenue/disconnect`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -export const updateRevenueCurrency = async (pid: string, currency: string): Promise<{ success: boolean }> => { - return api - .post(`project/${pid}/revenue/currency`, { currency }) - .then((response): { success: boolean } => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - export const getRevenueData = async ( pid: string, period: string, diff --git a/web/app/pages/Project/Settings/People/People.tsx b/web/app/pages/Project/Settings/People/People.tsx index 6baeb40ab..5cf3ccaa3 100644 --- a/web/app/pages/Project/Settings/People/People.tsx +++ b/web/app/pages/Project/Settings/People/People.tsx @@ -7,15 +7,16 @@ import _map from 'lodash/map' import { Trash2Icon, UserRoundPlusIcon } from 'lucide-react' import React, { useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { deleteShareProjectUsers, shareProject, changeShareRole } from '~/api' import useOnClickOutside from '~/hooks/useOnClickOutside' import { roles, INVITATION_EXPIRES_IN, isSelfhosted } from '~/lib/constants' import { Role } from '~/lib/models/Organisation' import { Project, ShareOwnerProject } from '~/lib/models/Project' import PaidFeature from '~/modals/PaidFeature' import { useAuth } from '~/providers/AuthProvider' +import { ProjectSettingsActionData } from '~/routes/projects.settings.$id' import { Badge } from '~/ui/Badge' import Button from '~/ui/Button' import Input from '~/ui/Input' @@ -39,26 +40,34 @@ interface TableUserRowProps { onRemove: () => void language: string authedUserEmail: string | undefined - reloadProject: () => Promise + projectId: string } -const TableUserRow = ({ data, onRemove, language, authedUserEmail, reloadProject }: TableUserRowProps) => { +const TableUserRow = ({ data, onRemove, language, authedUserEmail, projectId }: TableUserRowProps) => { const { t } = useTranslation('common') const [open, setOpen] = useState(false) const openRef = useRef(null) useOnClickOutside(openRef, () => setOpen(false)) + const fetcher = useFetcher() const { id, created, confirmed, role, user } = data || {} - const changeRole = async (newRole: string) => { - try { - await changeShareRole(id, { role: newRole }) - await reloadProject() - toast.success(t('apiNotifications.roleUpdated')) - } catch (reason) { - console.error(`[ERROR] Error while updating user's role: ${reason}`) - toast.error(t('apiNotifications.roleUpdateError')) + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data) { + if (fetcher.data.intent === 'change-share-role') { + if (fetcher.data.success) { + toast.success(t('apiNotifications.roleUpdated')) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + } } + }, [fetcher.state, fetcher.data, t]) + const changeRole = (newRole: string) => { + fetcher.submit( + { intent: 'change-share-role', shareId: id, role: newRole }, + { method: 'POST', action: `/projects/settings/${projectId}` }, + ) setOpen(false) } @@ -140,11 +149,11 @@ const TableUserRow = ({ data, onRemove, language, authedUserEmail, reloadProject interface PeopleProps { project: Project - reloadProject: () => Promise } -const People = ({ project, reloadProject }: PeopleProps) => { +const People = ({ project }: PeopleProps) => { const { user: currentUser } = useAuth() + const fetcher = useFetcher() const [showModal, setShowModal] = useState(false) const [isPaidFeatureOpened, setIsPaidFeatureOpened] = useState(false) @@ -167,11 +176,37 @@ const People = ({ project, reloadProject }: PeopleProps) => { const [validated, setValidated] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) const [memberToRemove, setMemberToRemove] = useState(null) const { id, name, share } = project + const isSubmitting = fetcher.state !== 'idle' + + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data) { + if (fetcher.data.intent === 'share-project') { + if (fetcher.data.success) { + toast.success(t('apiNotifications.userInvited')) + setShowModal(false) + setBeenSubmitted(false) + setErrors({}) + setValidated(false) + setTimeout(() => setForm({ email: '', role: 'viewer' }), 300) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + } else if (fetcher.data.intent === 'delete-share-user') { + if (fetcher.data.success) { + toast.success(t('apiNotifications.userRemoved')) + setShowDeleteModal(false) + setMemberToRemove(null) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + } + } + }, [fetcher.state, fetcher.data, t]) + const validate = () => { const allErrors: { email?: string @@ -207,23 +242,11 @@ const People = ({ project, reloadProject }: PeopleProps) => { })) } - const onSubmit = async () => { - setShowModal(false) - setBeenSubmitted(false) - setErrors({}) - setValidated(false) - - try { - await shareProject(id, { email: form.email, role: form.role }) - await reloadProject() - toast.success(t('apiNotifications.userInvited')) - } catch (reason) { - console.error(`[ERROR] Error while inviting a user: ${reason}`) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.userInviteError')) - } - - // a timeout is needed to prevent the flicker of data fields in the modal when closing - setTimeout(() => setForm({ email: '', role: 'viewer' }), 300) + const onSubmit = () => { + fetcher.submit( + { intent: 'share-project', email: form.email, role: form.role }, + { method: 'POST', action: `/projects/settings/${id}` }, + ) } const handleSubmit = (e: React.MouseEvent) => { @@ -246,23 +269,11 @@ const People = ({ project, reloadProject }: PeopleProps) => { setErrors({}) } - const onRemove = async (member: ShareOwnerProject) => { - if (isDeleting) { - return - } - - setIsDeleting(true) - - try { - await deleteShareProjectUsers(id, member.id) - await reloadProject() - toast.success(t('apiNotifications.userRemoved')) - } catch (reason) { - console.error(`[ERROR] Error while deleting a user: ${reason}`) - toast.error(t('apiNotifications.userRemoveError')) - } finally { - setIsDeleting(false) - } + const onRemove = (member: ShareOwnerProject) => { + fetcher.submit( + { intent: 'delete-share-user', userId: member.id }, + { method: 'POST', action: `/projects/settings/${id}` }, + ) } return ( @@ -315,7 +326,7 @@ const People = ({ project, reloadProject }: PeopleProps) => { }} language={language} authedUserEmail={currentUser?.email} - reloadProject={reloadProject} + projectId={id} /> ))} @@ -330,7 +341,6 @@ const People = ({ project, reloadProject }: PeopleProps) => { setMemberToRemove(null) }} onSubmit={() => { - setShowDeleteModal(false) onRemove(memberToRemove!) }} submitText={t('common.yes')} @@ -339,7 +349,7 @@ const People = ({ project, reloadProject }: PeopleProps) => { title={t('project.settings.removeUser', { user: memberToRemove?.user?.email })} message={t('project.settings.removeConfirm')} isOpened={showDeleteModal} - isLoading={isDeleting} + isLoading={isSubmitting ? fetcher.formData?.get('intent') === 'delete-share-user' : null} /> { ) : null} {activeTab === 'emails' && !isSelfhosted ? : null} - {activeTab === 'people' ? : null} + {activeTab === 'people' ? : null} {activeTab === 'annotations' ? ( ) : null} diff --git a/web/app/pages/Project/Settings/tabs/Revenue.tsx b/web/app/pages/Project/Settings/tabs/Revenue.tsx index 21f8d5481..f43ffa21e 100644 --- a/web/app/pages/Project/Settings/tabs/Revenue.tsx +++ b/web/app/pages/Project/Settings/tabs/Revenue.tsx @@ -1,12 +1,12 @@ import { CheckCircleIcon } from '@heroicons/react/24/solid' import { ArrowUpRightIcon, ExternalLinkIcon } from 'lucide-react' -import React, { useCallback, useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { connectRevenue, disconnectRevenue, getRevenueStatus, updateRevenueCurrency } from '~/api' -import type { RevenueProvider } from '~/api' import { DOCS_URL } from '~/lib/constants' +import type { ProjectSettingsActionData, RevenueStatus } from '~/routes/projects.settings.$id' import Button from '~/ui/Button' import PaddleSVG from '~/ui/icons/Paddle' import StripeSVG from '~/ui/icons/Stripe' @@ -15,12 +15,7 @@ import Loader from '~/ui/Loader' import Select from '~/ui/Select' import { Text } from '~/ui/Text' -interface RevenueStatus { - connected: boolean - provider?: string - currency?: string - lastSyncAt?: string -} +type RevenueProvider = 'stripe' | 'paddle' const SUPPORTED_CURRENCIES = [ { code: 'USD', symbol: '$', name: 'US Dollar' }, @@ -85,82 +80,105 @@ interface Props { const Revenue = ({ projectId }: Props) => { const { t } = useTranslation('common') + const fetcher = useFetcher() const [status, setStatus] = useState(null) const [isLoading, setIsLoading] = useState(true) const [apiKey, setApiKey] = useState('') const [selectedProvider, setSelectedProvider] = useState('stripe') const [selectedCurrency, setSelectedCurrency] = useState<'USD' | 'EUR' | 'GBP'>('USD') - const [isConnecting, setIsConnecting] = useState(false) - const [isDisconnecting, setIsDisconnecting] = useState(false) - const [isSavingCurrency, setIsSavingCurrency] = useState(false) - - const loadStatus = useCallback(async () => { - try { - const result = await getRevenueStatus(projectId) - setStatus(result) - if (result.provider === 'stripe' || result.provider === 'paddle') { - setSelectedProvider(result.provider) - } - if (result.currency === 'USD' || result.currency === 'EUR' || result.currency === 'GBP') { - setSelectedCurrency(result.currency) - } - } catch (error) { - console.error('Failed to load revenue status:', error) - } finally { - setIsLoading(false) - } + + const isConnecting = + fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'connect-revenue' + const isDisconnecting = + fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'disconnect-revenue' + const isSavingCurrency = + fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'update-revenue-currency' + + useEffect(() => { + fetcher.submit( + { intent: 'get-revenue-status' }, + { method: 'POST', action: `/projects/settings/${projectId}` }, + ) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]) useEffect(() => { - loadStatus() - }, [loadStatus]) + if (fetcher.state === 'idle' && fetcher.data) { + const { intent, success, error, revenueStatus } = fetcher.data + + if (intent === 'get-revenue-status') { + setIsLoading(false) + if (success && revenueStatus) { + setStatus(revenueStatus) + if (revenueStatus.provider === 'stripe' || revenueStatus.provider === 'paddle') { + setSelectedProvider(revenueStatus.provider) + } + if ( + revenueStatus.currency === 'USD' || + revenueStatus.currency === 'EUR' || + revenueStatus.currency === 'GBP' + ) { + setSelectedCurrency(revenueStatus.currency) + } + } else if (error) { + console.error('Failed to load revenue status:', error) + } + } else if (intent === 'connect-revenue') { + if (success) { + toast.success(t('project.settings.revenue.connected')) + setApiKey('') + fetcher.submit( + { intent: 'get-revenue-status' }, + { method: 'POST', action: `/projects/settings/${projectId}` }, + ) + } else if (error) { + toast.error(error) + } + } else if (intent === 'disconnect-revenue') { + if (success) { + toast.success(t('project.settings.revenue.disconnected')) + setStatus({ connected: false }) + } else if (error) { + toast.error(error) + } + } else if (intent === 'update-revenue-currency') { + if (success) { + toast.success(t('project.settings.revenue.currencyUpdated')) + setStatus((prev) => (prev ? { ...prev, currency: selectedCurrency } : prev)) + } else if (error) { + toast.error(error) + } + } + } + }, [fetcher.state, fetcher.data, t, projectId, selectedCurrency]) - const handleConnect = async () => { + const handleConnect = () => { if (!apiKey.trim()) { toast.error(t('project.settings.revenue.apiKeyRequired')) return } - setIsConnecting(true) - try { - await connectRevenue(projectId, selectedProvider, apiKey, selectedCurrency) - toast.success(t('project.settings.revenue.connected')) - setApiKey('') - await loadStatus() - } catch (error: any) { - toast.error(typeof error === 'string' ? error : t('apiNotifications.somethingWentWrong')) - } finally { - setIsConnecting(false) - } + fetcher.submit( + { intent: 'connect-revenue', provider: selectedProvider, apiKey, currency: selectedCurrency }, + { method: 'POST', action: `/projects/settings/${projectId}` }, + ) } - const handleDisconnect = async () => { - setIsDisconnecting(true) - try { - await disconnectRevenue(projectId) - toast.success(t('project.settings.revenue.disconnected')) - setStatus({ connected: false }) - } catch (error: any) { - toast.error(typeof error === 'string' ? error : t('apiNotifications.somethingWentWrong')) - } finally { - setIsDisconnecting(false) - } + const handleDisconnect = () => { + fetcher.submit( + { intent: 'disconnect-revenue' }, + { method: 'POST', action: `/projects/settings/${projectId}` }, + ) } - const handleCurrencyChange = async () => { + const handleCurrencyChange = () => { if (!status?.connected) return - setIsSavingCurrency(true) - try { - await updateRevenueCurrency(projectId, selectedCurrency) - toast.success(t('project.settings.revenue.currencyUpdated')) - setStatus((prev) => (prev ? { ...prev, currency: selectedCurrency } : prev)) - } catch (error: any) { - toast.error(typeof error === 'string' ? error : t('apiNotifications.somethingWentWrong')) - } finally { - setIsSavingCurrency(false) - } + fetcher.submit( + { intent: 'update-revenue-currency', currency: selectedCurrency }, + { method: 'POST', action: `/projects/settings/${projectId}` }, + ) } if (isLoading) { diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 3b80f3b0c..d4572497a 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -12,6 +12,13 @@ export async function loader({ request }: LoaderFunctionArgs) { return { requestOrigin: request.headers.get('origin') } } +export interface RevenueStatus { + connected: boolean + provider?: string + currency?: string + lastSyncAt?: string +} + export interface ProjectSettingsActionData { success?: boolean intent?: string @@ -22,11 +29,13 @@ export interface ProjectSettingsActionData { ipBlacklist?: string password?: string transferEmail?: string + email?: string } project?: Project subscriber?: Subscriber subscribers?: Subscriber[] subscribersCount?: number + revenueStatus?: RevenueStatus } export async function action({ request, params }: ActionFunctionArgs) { @@ -300,6 +309,147 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } + case 'share-project': { + const email = formData.get('email')?.toString() + const role = formData.get('role')?.toString() + + if (!email) { + return data({ intent, fieldErrors: { email: 'Email is required' } }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${id}/share`, { + method: 'POST', + body: { email, role }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, project: result.data as Project }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-share-user': { + const userId = formData.get('userId')?.toString() + + if (!userId) { + return data({ intent, error: 'User ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${id}/${userId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'change-share-role': { + const shareId = formData.get('shareId')?.toString() + const role = formData.get('role')?.toString() + + if (!shareId) { + return data({ intent, error: 'Share ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/share/${shareId}`, { + method: 'PUT', + body: { role }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-revenue-status': { + const result = await serverFetch(request, `project/${id}/revenue/status`) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, revenueStatus: result.data as RevenueStatus }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'connect-revenue': { + const provider = formData.get('provider')?.toString() + const apiKey = formData.get('apiKey')?.toString() + const currency = formData.get('currency')?.toString() || 'USD' + + if (!provider || !apiKey) { + return data({ intent, error: 'Provider and API key are required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${id}/revenue/connect`, { + method: 'POST', + body: { provider, apiKey, currency }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'disconnect-revenue': { + const result = await serverFetch(request, `project/${id}/revenue/disconnect`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-revenue-currency': { + const currency = formData.get('currency')?.toString() + + if (!currency) { + return data({ intent, error: 'Currency is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `project/${id}/revenue/currency`, { + method: 'POST', + body: { currency }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } From 21f8ef6efe5fda61a6cd790c463a28c0810682dd Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Mon, 5 Jan 2026 22:27:34 +0000 Subject: [PATCH 14/57] move more stuff to ssr --- web/app/api/index.ts | 32 -- .../UserSettings/components/Integrations.tsx | 55 +-- .../UserSettings/components/Organisations.tsx | 74 ++-- .../components/Socialisations.tsx | 36 +- web/app/routes/user-settings.tsx | 85 +++++ web/app/utils/getPosts.ts | 345 ------------------ 6 files changed, 177 insertions(+), 450 deletions(-) delete mode 100644 web/app/utils/getPosts.ts diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 6b19a1f87..bf8ec3728 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -98,22 +98,6 @@ export const getProjects = (take = 0, skip = 0, search = '', period = '7d', sort throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const acceptOrganisationInvitation = (tokenId: string) => - api - .post(`/user/organisation/${tokenId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const rejectOrganisationInvitation = (tokenId: string) => - api - .delete(`/user/organisation/${tokenId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProject = (pid: string, password?: string) => api .get(`/project/${pid}`, { @@ -738,14 +722,6 @@ export const getLiveVisitorsInfo = (pid: string, password?: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const removeTgIntegration = (tgID: string) => - api - .delete(`user/tg/${tgID}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProjectAlerts = (projectId: string, take: number = DEFAULT_ALERTS_TAKE, skip = 0) => api .get(`/alert/project/${projectId}?take=${take}&skip=${skip}`) @@ -1401,14 +1377,6 @@ export const linkBySSOHash = (hash: string, provider: SSOProvider) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const unlinkSSO = (provider: SSOProvider) => - api - .delete('v1/auth/sso/unlink', { data: { provider } }) - .then((response): unknown => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const processSSOToken = (token: string, hash: string) => api .post('v1/auth/sso/process-token', { token, hash }) diff --git a/web/app/pages/UserSettings/components/Integrations.tsx b/web/app/pages/UserSettings/components/Integrations.tsx index 7a0186921..967b53401 100644 --- a/web/app/pages/UserSettings/components/Integrations.tsx +++ b/web/app/pages/UserSettings/components/Integrations.tsx @@ -1,13 +1,14 @@ import { CheckCircleIcon, XCircleIcon, ClockIcon } from '@heroicons/react/24/solid' import type i18next from 'i18next' import _map from 'lodash/map' -import React, { useState, memo } from 'react' +import React, { useState, memo, useEffect } from 'react' import { useTranslation, Trans } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { removeTgIntegration } from '~/api' import { User } from '~/lib/models/User' import { useAuth } from '~/providers/AuthProvider' +import type { UserSettingsActionData } from '~/routes/user-settings' import Button from '~/ui/Button' import Discord from '~/ui/icons/Discord' import Slack from '~/ui/icons/Slack' @@ -70,6 +71,23 @@ const Integrations = ({ handleIntegrationSave }: IntegrationsProps) => { const [isIntegrationLoading, setIsIntegrationLoading] = useState(false) const [isRemovalLoading, setIsRemovalLoading] = useState(false) + const fetcher = useFetcher() + const isTgRemovalLoading = fetcher.state !== 'idle' + + useEffect(() => { + if (fetcher.data?.intent === 'remove-tg-integration') { + if (fetcher.data.success) { + mergeUser({ + isTelegramChatIdConfirmed: false, + telegramChatId: null, + }) + trackCustom('INTEGRATION_REMOVED', { integration: 'telegram' }) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + } + }, [fetcher.data, mergeUser]) + const setupIntegration = (key: string) => () => { setIntegrationConfigurating(key) } @@ -166,31 +184,22 @@ const Integrations = ({ handleIntegrationSave }: IntegrationsProps) => { } const removeIntegration = async (key: string) => { - if (isRemovalLoading || !user) { + if (isRemovalLoading || isTgRemovalLoading || !user) { return } - setIsRemovalLoading(true) - if (key === 'telegram') { - try { - if (user.telegramChatId) { - await removeTgIntegration(user.telegramChatId) - } else { - throw new Error('No chat ID') - } - mergeUser({ - isTelegramChatIdConfirmed: false, - telegramChatId: null, - }) - } catch (reason: any) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.integrationRemovalError')) - console.error(`[ERROR] Failed to remove TG integration: ${reason}`) + if (!user.telegramChatId) { + toast.error(t('apiNotifications.integrationRemovalError')) + return } - setIsRemovalLoading(false) + fetcher.submit({ intent: 'remove-tg-integration', tgID: user.telegramChatId }, { method: 'POST' }) + return } + setIsRemovalLoading(true) + if (key === 'slack') { handleIntegrationSave( { @@ -199,6 +208,8 @@ const Integrations = ({ handleIntegrationSave }: IntegrationsProps) => { (isSuccess: boolean) => { if (!isSuccess) { toast.error(t('apiNotifications.integrationRemovalError')) + } else { + trackCustom('INTEGRATION_REMOVED', { integration: key }) } mergeUser({ @@ -217,6 +228,8 @@ const Integrations = ({ handleIntegrationSave }: IntegrationsProps) => { (isSuccess: boolean) => { if (!isSuccess) { toast.error(t('apiNotifications.integrationRemovalError')) + } else { + trackCustom('INTEGRATION_REMOVED', { integration: key }) } mergeUser({ @@ -226,10 +239,6 @@ const Integrations = ({ handleIntegrationSave }: IntegrationsProps) => { }, ) } - - trackCustom('INTEGRATION_REMOVED', { - integration: key, - }) } if (integrationConfigurating) { diff --git a/web/app/pages/UserSettings/components/Organisations.tsx b/web/app/pages/UserSettings/components/Organisations.tsx index fcca74048..4f2ef0b47 100644 --- a/web/app/pages/UserSettings/components/Organisations.tsx +++ b/web/app/pages/UserSettings/components/Organisations.tsx @@ -2,13 +2,14 @@ import cx from 'clsx' import dayjs from 'dayjs' import _filter from 'lodash/filter' import _map from 'lodash/map' -import { memo, useState } from 'react' +import { memo, useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { rejectOrganisationInvitation, acceptOrganisationInvitation } from '~/api' import { OrganisationMembership } from '~/lib/models/Organisation' import { useAuth } from '~/providers/AuthProvider' +import type { UserSettingsActionData } from '~/routes/user-settings' import Button from '~/ui/Button' import Modal from '~/ui/Modal' @@ -26,42 +27,47 @@ const Organisations = ({ membership }: OrganisationsProps) => { const [showDeleteModal, setShowDeleteModal] = useState(false) const { organisation, confirmed, role, created } = membership - const onQuit = async () => { - try { - await rejectOrganisationInvitation(membership.id) + const fetcher = useFetcher() + const isLoading = fetcher.state !== 'idle' - mergeUser({ - organisationMemberships: _filter( - user?.organisationMemberships, - (userMembership) => userMembership.id !== membership.id, - ), - }) - - toast.success(t('apiNotifications.quitOrganisation')) - } catch (reason) { - console.error(`[ERROR] Error while quitting project: ${reason}`) - toast.error(t('apiNotifications.quitOrganisationError')) + useEffect(() => { + if (fetcher.data?.intent === 'reject-organisation-invitation') { + if (fetcher.data.success) { + mergeUser({ + organisationMemberships: _filter( + user?.organisationMemberships, + (userMembership) => userMembership.id !== membership.id, + ), + }) + toast.success(t('apiNotifications.quitOrganisation')) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } } - } - const onAccept = async () => { - try { - await acceptOrganisationInvitation(membership.id) + if (fetcher.data?.intent === 'accept-organisation-invitation') { + if (fetcher.data.success) { + mergeUser({ + organisationMemberships: _map(user?.organisationMemberships, (item) => { + if (item.id === membership.id) { + return { ...item, confirmed: true } + } + return item + }), + }) + toast.success(t('apiNotifications.acceptInvitation')) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + } + }, [fetcher.data, mergeUser, membership.id, t, user?.organisationMemberships]) - mergeUser({ - organisationMemberships: _map(user?.organisationMemberships, (item) => { - if (item.id === membership.id) { - return { ...item, confirmed: true } - } - return item - }), - }) + const onQuit = () => { + fetcher.submit({ intent: 'reject-organisation-invitation', membershipId: membership.id }, { method: 'POST' }) + } - toast.success(t('apiNotifications.acceptInvitation')) - } catch (reason) { - console.error(`[ERROR] Error while accepting project invitation: ${reason}`) - toast.error(t('apiNotifications.acceptInvitationError')) - } + const onAccept = () => { + fetcher.submit({ intent: 'accept-organisation-invitation', membershipId: membership.id }, { method: 'POST' }) } return ( @@ -89,7 +95,7 @@ const Organisations = ({ membership }: OrganisationsProps) => { - diff --git a/web/app/pages/UserSettings/components/Socialisations.tsx b/web/app/pages/UserSettings/components/Socialisations.tsx index 7cbd90c68..0e24ab7dd 100644 --- a/web/app/pages/UserSettings/components/Socialisations.tsx +++ b/web/app/pages/UserSettings/components/Socialisations.tsx @@ -1,15 +1,17 @@ import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/solid' import _map from 'lodash/map' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { generateSSOAuthURL, linkBySSOHash, unlinkSSO } from '~/api' +import { generateSSOAuthURL, linkBySSOHash } from '~/api' import { SSO_PROVIDERS } from '~/lib/constants' import { SSOProvider } from '~/lib/models/Auth' import { User } from '~/lib/models/User' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' +import type { UserSettingsActionData } from '~/routes/user-settings' import Button from '~/ui/Button' import GithubDark from '~/ui/icons/GithubDark' import GithubLight from '~/ui/icons/GithubLight' @@ -63,6 +65,19 @@ const Socialisations = () => { const { theme } = useTheme() const { t } = useTranslation('common') const [isLoading, setIsLoading] = useState(false) + const fetcher = useFetcher() + const isUnlinking = fetcher.state !== 'idle' + + useEffect(() => { + if (fetcher.data?.intent === 'unlink-sso') { + if (fetcher.data.success) { + loadUser() + toast.success(t('apiNotifications.socialAccountUninked')) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + } + }, [fetcher.data, loadUser, t]) const linkSSO = async (provider: SSOProvider) => { setIsLoading(true) @@ -135,19 +150,8 @@ const Socialisations = () => { } } - const onUnlinkSSO = async (provider: SSOProvider) => { - setIsLoading(true) - - try { - await unlinkSSO(provider) - await loadUser() - - toast.success(t('apiNotifications.socialAccountUninked')) - } catch (reason) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.socialisationUnlinkGenericError')) - } finally { - setIsLoading(false) - } + const onUnlinkSSO = (provider: SSOProvider) => { + fetcher.submit({ intent: 'unlink-sso', provider }, { method: 'POST' }) } return ( @@ -212,7 +216,7 @@ const Socialisations = () => { ) : null} {connected && unlinkable ? ( - ) : null} diff --git a/web/app/routes/user-settings.tsx b/web/app/routes/user-settings.tsx index d6fd9bf6d..1727775c8 100644 --- a/web/app/routes/user-settings.tsx +++ b/web/app/routes/user-settings.tsx @@ -294,6 +294,91 @@ export async function action({ request }: ActionFunctionArgs) { ) } + case 'unlink-sso': { + const provider = formData.get('provider')?.toString() + + if (!provider) { + return data({ intent, error: 'Provider is required' }, { status: 400 }) + } + + const result = await serverFetch(request, 'v1/auth/sso/unlink', { + method: 'DELETE', + body: { provider }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'remove-tg-integration': { + const tgID = formData.get('tgID')?.toString() + + if (!tgID) { + return data({ intent, error: 'Telegram ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `user/tg/${tgID}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'accept-organisation-invitation': { + const membershipId = formData.get('membershipId')?.toString() + + if (!membershipId) { + return data({ intent, error: 'Membership ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `user/organisation/${membershipId}`, { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'reject-organisation-invitation': { + const membershipId = formData.get('membershipId')?.toString() + + if (!membershipId) { + return data({ intent, error: 'Membership ID is required' }, { status: 400 }) + } + + const result = await serverFetch(request, `user/organisation/${membershipId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } diff --git a/web/app/utils/getPosts.ts b/web/app/utils/getPosts.ts deleted file mode 100644 index 792f1dc39..000000000 --- a/web/app/utils/getPosts.ts +++ /dev/null @@ -1,345 +0,0 @@ -import _isEmpty from 'lodash/isEmpty' -import { marked, type Tokens } from 'marked' -import sanitizeHtml from 'sanitize-html' - -import { getBlogPost, getBlogPostWithCategory } from '~/api' - -import { renderTimeToSwitchCta, renderDitchGoogleCta } from './renderCtaHtml' -import { extractTableOfContents, ensureHeaderIds, generateSlug, renderTocAsHtml } from './toc' - -const renderer = new marked.Renderer() - -renderer.link = ({ href, text }: Tokens.Link) => { - let url: URL - - try { - url = new URL(href, 'https://swetrix.com') - } catch { - // If the href is invalid, render plain text to avoid breaking post rendering. - return text - } - - if (url.hostname !== 'swetrix.com') { - url.searchParams.set('utm_source', 'swetrix.com') - } - - return `${text}` -} - -// Add automatic ID generation to headers -renderer.heading = ({ text, depth }: Tokens.Heading) => { - const id = generateSlug(text) - return `${text}` -} - -// Helper to parse inline markdown in table cells -function parseInlineMarkdown(text: string): string { - return marked.parseInline(text) as string -} - -// Check if a row is a section header (first cell is bold, other cells are empty) -function isSectionHeaderRow(row: Tokens.TableCell[]): boolean { - const firstCellText = row[0]?.text?.trim() || '' - const isBold = firstCellText.startsWith('**') && firstCellText.endsWith('**') - const otherCellsEmpty = row.slice(1).every((cell) => !cell.text?.trim()) - return isBold && otherCellsEmpty -} - -// Custom table rendering with improved styling -renderer.table = ({ header, rows }: Tokens.Table) => { - const headerCells = header - .map( - (cell) => - `${parseInlineMarkdown(cell.text)}`, - ) - .join('') - - const bodyRows = rows - .map((row) => { - const isHeader = isSectionHeaderRow(row) - - if (isHeader) { - // Section header row - spans all columns with background - const cellContent = parseInlineMarkdown(row[0].text) - return `${cellContent}` - } - - // Regular row - return `${row - .map((cell) => { - return `${parseInlineMarkdown(cell.text)}` - }) - .join('')}` - }) - .join('') - - return `
- - ${headerCells} - ${bodyRows} -
-
` -} - -// SVG icons for checkmark, cross, and warning -const CHECK_ICON_SVG = `` - -const CROSS_ICON_SVG = `` - -const WARNING_ICON_SVG = `` - -// Inline Swetrix logo that inherits font size -const SWETRIX_LOGO_HTML = `Swetrix` - -// GitHub-style alert configurations -const ALERT_CONFIGS = { - NOTE: { - icon: ``, - bgClass: 'bg-blue-50 dark:bg-blue-950/50', - borderClass: 'border-blue-400 dark:border-blue-500', - iconClass: 'text-blue-500 dark:text-blue-400', - titleClass: 'text-blue-800 dark:text-blue-200', - contentClass: 'text-blue-700 dark:text-blue-300', - title: 'Note', - }, - TIP: { - icon: ``, - bgClass: 'bg-emerald-50 dark:bg-emerald-950/50', - borderClass: 'border-emerald-400 dark:border-emerald-500', - iconClass: 'text-emerald-500 dark:text-emerald-400', - titleClass: 'text-emerald-800 dark:text-emerald-200', - contentClass: 'text-emerald-700 dark:text-emerald-300', - title: 'Tip', - }, - IMPORTANT: { - icon: ``, - bgClass: 'bg-purple-50 dark:bg-purple-950/50', - borderClass: 'border-purple-400 dark:border-purple-500', - iconClass: 'text-purple-500 dark:text-purple-400', - titleClass: 'text-purple-800 dark:text-purple-200', - contentClass: 'text-purple-700 dark:text-purple-300', - title: 'Important', - }, - WARNING: { - icon: ``, - bgClass: 'bg-amber-50 dark:bg-amber-950/50', - borderClass: 'border-amber-400 dark:border-amber-500', - iconClass: 'text-amber-500 dark:text-amber-400', - titleClass: 'text-amber-800 dark:text-amber-200', - contentClass: 'text-amber-700 dark:text-amber-300', - title: 'Warning', - }, - CAUTION: { - icon: ``, - bgClass: 'bg-red-50 dark:bg-red-950/50', - borderClass: 'border-red-400 dark:border-red-500', - iconClass: 'text-red-500 dark:text-red-400', - titleClass: 'text-red-800 dark:text-red-200', - contentClass: 'text-red-700 dark:text-red-300', - title: 'Caution', - }, -} as const - -type AlertType = keyof typeof ALERT_CONFIGS - -// Custom blockquote rendering for GitHub-style alerts -renderer.blockquote = ({ text }: Tokens.Blockquote) => { - // Check for alert syntax: [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION] - // The text may be raw markdown or partially rendered, check for both formats - const alertMatch = text.match(/^\s*(?:

)?\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](?:|\n)?(?:<\/p>)?\s*/i) - - if (alertMatch) { - const alertType = alertMatch[1].toUpperCase() as AlertType - const config = ALERT_CONFIGS[alertType] - const rawContent = text.replace(alertMatch[0], '').trim() - - // Process content through marked to render markdown (links, bold, italic, etc.) - const content = marked(rawContent, { renderer }) as string - - return `

-
- ${config.icon} -
-

${config.title}

-
${content}
-
-
-
` - } - - // Default blockquote rendering - also process through marked for consistency - const renderedText = marked(text, { renderer }) as string - return `
${renderedText}
` -} - -// Function to replace emoji with styled icons -function replaceEmojiWithIcons(html: string): string { - return html.replace(/✅/g, CHECK_ICON_SVG).replace(/❌/g, CROSS_ICON_SVG).replace(/⚠️/g, WARNING_ICON_SVG) -} - -// Function to replace ::SWETRIX_LOGO:: placeholder -function replaceSwetrixLogo(html: string): string { - return html.replace(/::SWETRIX_LOGO::/g, SWETRIX_LOGO_HTML) -} - -// Removes first 10 characters from the string (i.e. 2023-10-07-) -export const getSlugFromFilename = (filename: string) => filename.substring(11) -export const getDateFromFilename = (filename: string) => filename.substring(0, 10) - -interface GetPost { - slug: string - title?: string - html: string - hidden?: boolean - standalone?: boolean - intro?: string - date?: string - author?: string - twitter_handle?: string -} - -export async function getPost(slug: string, category?: string, tryStandalone?: boolean): Promise { - let post: any = null - - try { - if (category) { - post = await getBlogPostWithCategory(category, slug) - } else { - post = await getBlogPost(slug) - } - } catch { - return null - } - - if (!post || _isEmpty(post)) { - return null - } - - if (!!tryStandalone !== !!post.attributes?.standalone) { - return null - } - - const tocPlaceholder = '::TABLE_OF_CONTENTS::' - const ctaTimeToSwitchPlaceholder = '::CTA:TIME_TO_SWITCH::' - const ctaDitchGooglePlaceholder = '::CTA:TIME_TO_DITCH_GOOGLE::' - const hasTocPlaceholder = post.body.includes(tocPlaceholder) - const hasCtaTimeToSwitch = post.body.includes(ctaTimeToSwitchPlaceholder) - const hasCtaDitchGoogle = post.body.includes(ctaDitchGooglePlaceholder) - - let html = marked(post.body, { renderer }) as string - - html = ensureHeaderIds(html) - - // Replace emoji icons with styled SVG icons - html = replaceEmojiWithIcons(html) - - // Replace ::SWETRIX_LOGO:: with inline logo - html = replaceSwetrixLogo(html) - - const toc = extractTableOfContents(html) - - if (hasTocPlaceholder && toc.length > 0) { - const tocHtml = renderTocAsHtml(toc) - html = html.replace(new RegExp(tocPlaceholder, 'gi'), tocHtml) - } - - // Handle CTA:TIME_TO_SWITCH placeholder - if (hasCtaTimeToSwitch) { - const ctaHtml = renderTimeToSwitchCta() - html = html.replace(new RegExp(ctaTimeToSwitchPlaceholder, 'gi'), ctaHtml) - } - - // Handle CTA:TIME_TO_DITCH_GOOGLE placeholder - if (hasCtaDitchGoogle) { - const ctaHtml = renderDitchGoogleCta() - html = html.replace(new RegExp(ctaDitchGooglePlaceholder, 'gi'), ctaHtml) - } - - // Sanitize generated HTML before sending it to the client to prevent stored XSS - // (CMS content, table-of-contents placeholder, and CTA placeholders). - html = sanitizeHtml(html, { - allowedTags: [ - // Document structure - 'p', - 'br', - 'hr', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - // Text formatting - 'strong', - 'em', - 's', - 'del', - 'blockquote', - 'pre', - 'code', - // Lists - 'ul', - 'ol', - 'li', - // Links / media - 'a', - 'img', - // Tables - 'table', - 'thead', - 'tbody', - 'tr', - 'th', - 'td', - // Layout used by CTAs / alerts / icons - 'div', - 'span', - // Inline SVG used by our generated HTML snippets - 'svg', - 'path', - ], - allowedAttributes: { - '*': ['class', 'id', 'role', 'aria-label', 'aria-hidden'], - a: ['href', 'target', 'rel', 'referrerpolicy', 'title'], - img: ['src', 'alt', 'title', 'width', 'height', 'loading'], - th: ['colspan', 'rowspan'], - td: ['colspan', 'rowspan'], - svg: [ - 'class', - 'xmlns', - 'viewbox', - 'width', - 'height', - 'fill', - 'stroke', - 'stroke-width', - 'stroke-linecap', - 'stroke-linejoin', - 'role', - 'aria-label', - ], - path: ['d', 'fill', 'fill-rule', 'clip-rule'], - }, - // Keep it tight: no javascript: URLs, no data: URLs (especially for ), - // but allow relative links and hash fragments. - allowedSchemes: ['http', 'https', 'mailto', 'tel'], - allowedSchemesByTag: { - img: ['http', 'https'], - }, - allowProtocolRelative: false, - // Avoid CSS injection; we only rely on Tailwind classes. - allowedStyles: {}, - }) - - return { - slug, - title: post.attributes?.title, - html, - hidden: post.attributes?.hidden, - intro: post.attributes?.intro, - date: post.attributes?.date, - author: post.attributes?.author, - twitter_handle: post.attributes?.twitter_handle, - standalone: post.attributes?.standalone, - } -} From 639f43d35e2eab7ba7d4cfbd69c85e25acebd3d4 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 6 Jan 2026 00:15:55 +0000 Subject: [PATCH 15/57] more segments and AI chat to actions --- web/app/api/index.ts | 133 +-------- web/app/hooks/useAnnotations.ts | 171 +++++------ web/app/modals/AnnotationModal.tsx | 12 +- web/app/pages/Project/View/ViewProject.tsx | 69 +++-- .../Project/View/components/AddAViewModal.tsx | 61 ++-- .../pages/Project/tabs/AskAI/AskAIView.tsx | 253 +++++++++++------ .../tabs/Traffic/TrafficHeaderActions.tsx | 74 +++-- .../Project/tabs/Traffic/TrafficView.tsx | 8 +- web/app/routes/blog.$slug.tsx | 1 - web/app/routes/projects.$id.tsx | 266 ++++++++++++++++++ 10 files changed, 655 insertions(+), 393 deletions(-) diff --git a/web/app/api/index.ts b/web/app/api/index.ts index bf8ec3728..3ee93d523 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -569,54 +569,6 @@ export const getFunnels = (pid: string, password: string | undefined = '') => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getProjectViews = (pid: string, password: string | undefined = '') => - api - .get(`project/${pid}/views`, { - headers: { - 'x-password': password, - }, - }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const createProjectView = ( - pid: string, - name: string, - type: 'traffic' | 'performance', - filters: Filter[], - customEvents: Partial[], -) => - api - .post(`project/${pid}/views`, { name, type, filters, customEvents }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const updateProjectView = ( - pid: string, - viewId: string, - name: string, - filters: Filter[], - customEvents: Partial[], -) => - api - .patch(`project/${pid}/views/${viewId}`, { name, filters, customEvents }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const deleteProjectView = (pid: string, viewId: string) => - api - .delete(`project/${pid}/views/${viewId}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getCaptchaData = (pid: string, tb = 'hour', period = '3d', filters: any[] = [], from = '', to = '') => api .get( @@ -1676,82 +1628,6 @@ export interface AIChatSummary { updated: string } -interface AIChat extends AIChatSummary { - messages: AIChatMessage[] - isOwner?: boolean - branched?: boolean -} - -export const getRecentAIChats = async (pid: string, limit: number = 5): Promise => { - return api - .get(`ai/${pid}/chats`, { - params: { limit }, - }) - .then((response): AIChatSummary[] => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -export const getAllAIChats = async ( - pid: string, - skip: number = 0, - take: number = 20, -): Promise<{ chats: AIChatSummary[]; total: number }> => { - return api - .get(`ai/${pid}/chats/all`, { - params: { skip, take }, - }) - .then((response): { chats: AIChatSummary[]; total: number } => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -export const getAIChat = async (pid: string, chatId: string): Promise => { - return api - .get(`ai/${pid}/chats/${chatId}`) - .then((response): AIChat => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -export const createAIChat = async (pid: string, messages: AIChatMessage[], name?: string): Promise => { - return api - .post( - `ai/${pid}/chats`, - { messages, name }, - { headers: { Authorization: getAccessToken() ? `Bearer ${getAccessToken()}` : '' } }, - ) - .then((response): AIChat => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -export const updateAIChat = async ( - pid: string, - chatId: string, - data: { messages?: AIChatMessage[]; name?: string }, -): Promise => { - return api - .post(`ai/${pid}/chats/${chatId}`, data) - .then((response): AIChat => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -export const deleteAIChat = async (pid: string, chatId: string): Promise<{ success: boolean }> => { - return api - .delete(`ai/${pid}/chats/${chatId}`) - .then((response): { success: boolean } => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - // Revenue API interface RevenueStatus { connected: boolean @@ -1804,3 +1680,12 @@ export const getRevenueData = async ( throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message }) } + +// Blog Sitemap (kept client-side for SitemapFunction - no request object available) +export const getSitemap = () => + api + .get('v1/blog/sitemap') + .then((response): (string | string[])[] => response.data) + .catch((error) => { + throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message + }) diff --git a/web/app/hooks/useAnnotations.ts b/web/app/hooks/useAnnotations.ts index 6fd7b329d..5c5f21e1d 100644 --- a/web/app/hooks/useAnnotations.ts +++ b/web/app/hooks/useAnnotations.ts @@ -1,29 +1,20 @@ import dayjs from 'dayjs' import { useState, useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { getAnnotations, createAnnotation, updateAnnotation, deleteAnnotation } from '~/api' import { Annotation } from '~/lib/models/Project' import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import { ProjectViewActionData } from '~/routes/projects.$id' -/** - * Pixel distance threshold for snapping to nearby annotations when clicking on the chart. - * If a click is within this many pixels of an annotation line, that annotation will be selected. - */ const ANNOTATION_CLICK_THRESHOLD_PX = 30 -/** Chart metrics derived from SVG/DOM layout */ interface ChartMetrics { - /** X coordinate where the chart plotting area begins (left edge of x-axis) */ chartAreaStart: number - /** Width of the chart plotting area */ chartAreaWidth: number } -/** - * Traverses up the DOM tree from a target element to find an annotation line element. - */ const findAnnotationLineElement = (target: Element, boundary: Element): Element | null => { let element: Element | null = target @@ -37,9 +28,6 @@ const findAnnotationLineElement = (target: Element, boundary: Element): Element return null } -/** - * Extracts the annotation ID from an annotation line element's class list. - */ const getAnnotationIdFromElement = (element: Element): string | null => { const classes = element.classList for (const className of classes) { @@ -50,17 +38,12 @@ const getAnnotationIdFromElement = (element: Element): string | null => { return null } -/** - * Calculates the chart area metrics by examining the SVG's x-axis domain path. - */ const calculateChartMetrics = (svg: SVGSVGElement, chartContainer: HTMLElement): ChartMetrics => { const svgRect = svg.getBoundingClientRect() - // Default fallbacks based on typical billboard.js chart layout let chartAreaStart = 50 let chartAreaWidth = svgRect.width - 70 - // Try to get exact boundaries from the x-axis domain path (the axis line) const xAxisPath = chartContainer.querySelector('.bb-axis-x path.domain') as SVGPathElement | null if (xAxisPath) { const pathBBox = xAxisPath.getBBox() @@ -71,9 +54,6 @@ const calculateChartMetrics = (svg: SVGSVGElement, chartContainer: HTMLElement): return { chartAreaStart, chartAreaWidth } } -/** - * Calculates the date corresponding to a click position on the chart. - */ const calculateDateFromPosition = ( clientX: number, svgRect: DOMRect, @@ -83,20 +63,15 @@ const calculateDateFromPosition = ( const clickX = clientX - svgRect.left const { chartAreaStart, chartAreaWidth } = chartMetrics - // Calculate relative position within the chart area (clamped to [0, 1]) const relativeX = Math.max(0, clickX - chartAreaStart) const percentage = Math.min(1, relativeX / chartAreaWidth) - // Map percentage to index in xAxisData const index = Math.round(percentage * (xAxisData.length - 1)) const clampedIndex = Math.max(0, Math.min(xAxisData.length - 1, index)) return dayjs(xAxisData[clampedIndex]).format('YYYY-MM-DD') } -/** - * Finds the closest annotation to a click position within the pixel threshold. - */ const findClosestAnnotation = ( clickX: number, annotations: Annotation[], @@ -112,11 +87,9 @@ const findClosestAnnotation = ( for (const annotation of annotations) { const annotationDate = dayjs(annotation.date).format('YYYY-MM-DD') - // Find the index of this annotation's date in xAxisData const annotationIndex = xAxisData.findIndex((xDate) => dayjs(xDate).format('YYYY-MM-DD') === annotationDate) if (annotationIndex !== -1) { - // Calculate the x position of this annotation on the chart const annotationPercentage = annotationIndex / (xAxisData.length - 1) const annotationX = chartAreaStart + annotationPercentage * chartAreaWidth const distance = Math.abs(clickX - annotationX) @@ -132,7 +105,6 @@ const findClosestAnnotation = ( } interface UseAnnotationsReturn { - // State annotations: Annotation[] annotationsLoading: boolean isAnnotationModalOpen: boolean @@ -147,11 +119,10 @@ interface UseAnnotationsReturn { annotation: Annotation | null } - // Actions - loadAnnotations: () => Promise - onAnnotationCreate: (date: string, text: string) => Promise - onAnnotationUpdate: (date: string, text: string) => Promise - onAnnotationDelete: (annotation?: Annotation) => Promise + loadAnnotations: () => void + onAnnotationCreate: (date: string, text: string) => void + onAnnotationUpdate: (date: string, text: string) => void + onAnnotationDelete: (annotation?: Annotation) => void openAnnotationModal: (date?: string, annotation?: Annotation) => void closeAnnotationModal: () => void handleChartContextMenu: (event: React.MouseEvent, xAxisData: string[] | undefined) => void @@ -163,15 +134,16 @@ export const useAnnotations = (): UseAnnotationsReturn => { const projectPassword = useProjectPassword(id) const { t } = useTranslation('common') - // Annotations state + const fetcher = useFetcher() + const loadFetcher = useFetcher() + const [annotations, setAnnotations] = useState([]) const [annotationsLoading, setAnnotationsLoading] = useState(false) const [isAnnotationModalOpen, setIsAnnotationModalOpen] = useState(false) const [annotationToEdit, setAnnotationToEdit] = useState() const [annotationModalDate, setAnnotationModalDate] = useState() - const [annotationActionLoading, setAnnotationActionLoading] = useState(false) + const [pendingAction, setPendingAction] = useState<'create' | 'update' | 'delete' | null>(null) - // Chart context menu state const [contextMenu, setContextMenu] = useState<{ isOpen: boolean x: number @@ -186,90 +158,100 @@ export const useAnnotations = (): UseAnnotationsReturn => { annotation: null, }) - const loadAnnotations = useCallback(async () => { - if (annotationsLoading) { + const loadAnnotations = useCallback(() => { + if (loadFetcher.state !== 'idle') { return } setAnnotationsLoading(true) + const formData = new FormData() + formData.append('intent', 'get-annotations') + if (projectPassword) { + formData.append('password', projectPassword) + } + + loadFetcher.submit(formData, { method: 'POST' }) + }, [loadFetcher, projectPassword]) - try { - const data = await getAnnotations(id, projectPassword) - setAnnotations(data || []) - } catch (reason: any) { - console.error('[ERROR] (loadAnnotations)', reason) + useEffect(() => { + if (loadFetcher.state === 'idle' && loadFetcher.data) { + setAnnotationsLoading(false) + if (loadFetcher.data.success && loadFetcher.data.data) { + setAnnotations((loadFetcher.data.data as Annotation[]) || []) + } } + }, [loadFetcher.state, loadFetcher.data]) - setAnnotationsLoading(false) - }, [id, projectPassword, annotationsLoading]) + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data && pendingAction) { + if (fetcher.data.success) { + if (pendingAction === 'create') { + toast.success(t('apiNotifications.annotationCreated')) + } else if (pendingAction === 'update') { + toast.success(t('apiNotifications.annotationUpdated')) + } else if (pendingAction === 'delete') { + toast.success(t('apiNotifications.annotationDeleted')) + } + loadAnnotations() + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + setPendingAction(null) + setAnnotationToEdit(undefined) + } + }, [fetcher.state, fetcher.data, pendingAction, loadAnnotations, t]) const onAnnotationCreate = useCallback( - async (date: string, text: string) => { - if (annotationActionLoading) { + (date: string, text: string) => { + if (fetcher.state !== 'idle') { return } - setAnnotationActionLoading(true) - - try { - await createAnnotation(id, date, text) - await loadAnnotations() - toast.success(t('apiNotifications.annotationCreated')) - } catch (reason: any) { - console.error('[ERROR] (onAnnotationCreate)', reason) - toast.error(reason) - } + setPendingAction('create') + const formData = new FormData() + formData.append('intent', 'create-annotation') + formData.append('date', date) + formData.append('text', text) - setAnnotationActionLoading(false) + fetcher.submit(formData, { method: 'POST' }) }, - [id, annotationActionLoading, loadAnnotations, t], + [fetcher], ) const onAnnotationUpdate = useCallback( - async (date: string, text: string) => { - if (annotationActionLoading || !annotationToEdit) { + (date: string, text: string) => { + if (fetcher.state !== 'idle' || !annotationToEdit) { return } - setAnnotationActionLoading(true) - - try { - await updateAnnotation(annotationToEdit.id, id, date, text) - await loadAnnotations() - toast.success(t('apiNotifications.annotationUpdated')) - } catch (reason: any) { - console.error('[ERROR] (onAnnotationUpdate)', reason) - toast.error(reason) - } + setPendingAction('update') + const formData = new FormData() + formData.append('intent', 'update-annotation') + formData.append('annotationId', annotationToEdit.id) + formData.append('date', date) + formData.append('text', text) - setAnnotationActionLoading(false) + fetcher.submit(formData, { method: 'POST' }) }, - [id, annotationActionLoading, annotationToEdit, loadAnnotations, t], + [fetcher, annotationToEdit], ) const onAnnotationDelete = useCallback( - async (annotation?: Annotation) => { + (annotation?: Annotation) => { const targetAnnotation = annotation || annotationToEdit - if (annotationActionLoading || !targetAnnotation) { + if (fetcher.state !== 'idle' || !targetAnnotation) { return } - setAnnotationActionLoading(true) + setPendingAction('delete') + const formData = new FormData() + formData.append('intent', 'delete-annotation') + formData.append('annotationId', targetAnnotation.id) - try { - await deleteAnnotation(targetAnnotation.id, id) - await loadAnnotations() - toast.success(t('apiNotifications.annotationDeleted')) - } catch (reason: any) { - console.error('[ERROR] (onAnnotationDelete)', reason) - toast.error(reason) - } - - setAnnotationActionLoading(false) - setAnnotationToEdit(undefined) + fetcher.submit(formData, { method: 'POST' }) }, - [id, annotationActionLoading, annotationToEdit, loadAnnotations, t], + [fetcher, annotationToEdit], ) const openAnnotationModal = useCallback((date?: string, annotation?: Annotation) => { @@ -291,11 +273,9 @@ export const useAnnotations = (): UseAnnotationsReturn => { let existingAnnotation: Annotation | null = null let date: string | null = null - // Check if user clicked directly on an annotation line const annotationLineElement = findAnnotationLineElement(event.target as Element, event.currentTarget as Element) if (annotationLineElement) { - // User clicked on an annotation line - find it by ID from the element's class const annotationId = getAnnotationIdFromElement(annotationLineElement) if (annotationId) { existingAnnotation = annotations.find((a) => a.id === annotationId) || null @@ -305,7 +285,6 @@ export const useAnnotations = (): UseAnnotationsReturn => { } } - // If not clicking on an annotation line, calculate date from click position if (!existingAnnotation && xAxisData && xAxisData.length > 0) { const chartContainer = (event.currentTarget as HTMLElement).querySelector('.bb') as HTMLElement const svg = chartContainer?.querySelector('svg') as SVGSVGElement | null @@ -317,10 +296,8 @@ export const useAnnotations = (): UseAnnotationsReturn => { date = calculateDateFromPosition(event.clientX, svgRect, xAxisData, chartMetrics) - // Check for exact date match first existingAnnotation = annotations.find((a) => dayjs(a.date).format('YYYY-MM-DD') === date) || null - // If no exact match, try to find the closest annotation within threshold if (!existingAnnotation && annotations.length > 0) { const closestAnnotation = findClosestAnnotation( clickX, @@ -363,8 +340,9 @@ export const useAnnotations = (): UseAnnotationsReturn => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [project]) + const annotationActionLoading = fetcher.state !== 'idle' + return { - // State annotations, annotationsLoading, isAnnotationModalOpen, @@ -373,7 +351,6 @@ export const useAnnotations = (): UseAnnotationsReturn => { annotationActionLoading, contextMenu, - // Actions loadAnnotations, onAnnotationCreate, onAnnotationUpdate, diff --git a/web/app/modals/AnnotationModal.tsx b/web/app/modals/AnnotationModal.tsx index 7bab7e10b..3050025ec 100644 --- a/web/app/modals/AnnotationModal.tsx +++ b/web/app/modals/AnnotationModal.tsx @@ -13,8 +13,8 @@ const MAX_ANNOTATION_LENGTH = 120 interface AnnotationModalProps { onClose: () => void - onSubmit: (date: string, text: string) => Promise - onDelete?: () => Promise + onSubmit: (date: string, text: string) => void + onDelete?: () => void isOpened: boolean loading: boolean annotation?: Annotation @@ -57,21 +57,21 @@ const AnnotationModal = ({ onClose() } - const _onSubmit = async () => { + const _onSubmit = () => { if (!date || !text.trim() || !allowedToManage) { return } - await onSubmit(date, text.trim()) + onSubmit(date, text.trim()) _onClose() } - const _onDelete = async () => { + const _onDelete = () => { if (!onDelete || !allowedToManage) { return } - await onDelete() + onDelete() _onClose() } diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index b22f0f9ca..c11114649 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -26,11 +26,11 @@ import { import React, { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext, lazy } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { useTranslation } from 'react-i18next' -import { useNavigate, useSearchParams, LinkProps } from 'react-router' +import { useNavigate, useSearchParams, LinkProps, useFetcher } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { getProjectViews, deleteProjectView } from '~/api' +import { ProjectViewActionData } from '~/routes/projects.$id' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' import Footer from '~/components/Footer' import Header from '~/components/Header' @@ -402,49 +402,48 @@ const ViewProjectContent = () => { // null -> not loaded yet const [projectViews, setProjectViews] = useState([]) const [projectViewsLoading, setProjectViewsLoading] = useState(null) // // null - not loaded, true - loading, false - loaded - const [projectViewDeleting, setProjectViewDeleting] = useState(false) const [projectViewToUpdate, setProjectViewToUpdate] = useState() - const mode = activeChartMetrics[CHART_METRICS_MAPPING.cumulativeMode] ? 'cumulative' : 'periodical' + const viewsLoadFetcher = useFetcher() - const loadProjectViews = async (forced?: boolean) => { - if (!forced && projectViewsLoading !== null) { - return - } + const mode = activeChartMetrics[CHART_METRICS_MAPPING.cumulativeMode] ? 'cumulative' : 'periodical' - setProjectViewsLoading(true) + const loadProjectViews = useCallback( + (forced?: boolean) => { + if (!forced && projectViewsLoading !== null) { + return + } - try { - const views = await getProjectViews(id, projectPassword) - setProjectViews(views) - } catch (reason: any) { - console.error('[ERROR] (loadProjectViews)', reason) - toast.error(reason) - } + if (viewsLoadFetcher.state !== 'idle') { + return + } - setProjectViewsLoading(false) - } + setProjectViewsLoading(true) - const onProjectViewDelete = async (viewId: string) => { - if (projectViewDeleting) { - return - } + const formData = new FormData() + formData.append('intent', 'get-project-views') + if (projectPassword) { + formData.append('password', projectPassword) + } - setProjectViewDeleting(true) + viewsLoadFetcher.submit(formData, { method: 'POST' }) + }, + [projectViewsLoading, viewsLoadFetcher, projectPassword], + ) - try { - await deleteProjectView(id, viewId) - } catch (reason: any) { - console.error('[ERROR] (deleteProjectView)', reason) - toast.error(reason) - setProjectViewDeleting(false) - return + // Handle views load fetcher response + useEffect(() => { + if (viewsLoadFetcher.state === 'idle' && viewsLoadFetcher.data) { + setProjectViewsLoading(false) + if (viewsLoadFetcher.data.success && viewsLoadFetcher.data.data) { + setProjectViews((viewsLoadFetcher.data.data as ProjectView[]) || []) + } else if (viewsLoadFetcher.data.error) { + console.error('[ERROR] (loadProjectViews)', viewsLoadFetcher.data.error) + toast.error(viewsLoadFetcher.data.error) + } } + }, [viewsLoadFetcher.state, viewsLoadFetcher.data]) - toast.success(t('apiNotifications.segmentDeleted')) - await loadProjectViews(true) - setProjectViewDeleting(false) - } const getVersionFilterLink = useCallback( (parent: string | null, version: string | null, panelType: 'br' | 'os') => { @@ -1261,9 +1260,7 @@ const ViewProjectContent = () => { mode={mode} projectViews={projectViews} projectViewsLoading={projectViewsLoading} - projectViewDeleting={projectViewDeleting} loadProjectViews={loadProjectViews} - onProjectViewDelete={onProjectViewDelete} setProjectViewToUpdate={setProjectViewToUpdate} setIsAddAViewOpened={setIsAddAViewOpened} onCustomMetric={onCustomMetric} diff --git a/web/app/pages/Project/View/components/AddAViewModal.tsx b/web/app/pages/Project/View/components/AddAViewModal.tsx index e67d2e4e1..259f4d037 100644 --- a/web/app/pages/Project/View/components/AddAViewModal.tsx +++ b/web/app/pages/Project/View/components/AddAViewModal.tsx @@ -4,13 +4,15 @@ import _find from 'lodash/find' import _isEmpty from 'lodash/isEmpty' import _map from 'lodash/map' import _size from 'lodash/size' -import React, { useState, useEffect, useMemo, useCallback } from 'react' +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { getFilters, createProjectView, updateProjectView } from '~/api' +import { getFilters } from '~/api' import { FILTERS_PANELS_ORDER } from '~/lib/constants' import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import { ProjectViewActionData } from '~/routes/projects.$id' import Combobox from '~/ui/Combobox' import Input from '~/ui/Input' import Modal from '~/ui/Modal' @@ -194,12 +196,12 @@ const AddAViewModal = ({ onSubmit, showModal, setShowModal, tnMapping, defaultVi t, i18n: { language }, } = useTranslation('common') + const fetcher = useFetcher() const [name, setName] = useState(defaultView?.name || '') const [filterType, setFilterType] = useState('') const [searchList, setSearchList] = useState([]) const [activeFilters, setActiveFilters] = useState(defaultView?.filters || []) const [customEvents, setCustomEvents] = useState[]>(defaultView?.customEvents || []) - const [isViewSubmitting, setIsViewSubmitting] = useState(false) const [errors, setErrors] = useState({}) const getFiltersList = useCallback( @@ -308,7 +310,30 @@ const AddAViewModal = ({ onSubmit, showModal, setShowModal, tnMapping, defaultVi return valid } - const onViewCreate = async () => { + const isViewSubmitting = fetcher.state !== 'idle' + const hasHandledResponse = useRef(false) + + // Reset the handled flag when fetcher starts submitting + useEffect(() => { + if (fetcher.state === 'submitting') { + hasHandledResponse.current = false + } + }, [fetcher.state]) + + // Handle fetcher response + useEffect(() => { + if (fetcher.state === 'idle' && fetcher.data && !hasHandledResponse.current) { + hasHandledResponse.current = true + if (fetcher.data.success) { + onSubmit() + closeModal() + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + } + }, [fetcher.state, fetcher.data, onSubmit, closeModal]) + + const onViewCreate = () => { if (isViewSubmitting) { return } @@ -317,24 +342,20 @@ const AddAViewModal = ({ onSubmit, showModal, setShowModal, tnMapping, defaultVi return } - setIsViewSubmitting(true) - - try { - // updating an existing view - if (defaultView?.id) { - await updateProjectView(id, defaultView?.id, name, activeFilters, customEvents) - } else { - // crating a new one - await createProjectView(id, name, 'traffic', activeFilters, customEvents) - } - } catch (reason) { - console.error('[ERROR] onViewCreate:', reason) - toast.error(t('apiNotifications.somethingWentWrong')) + const formData = new FormData() + formData.append('filters', JSON.stringify(activeFilters)) + formData.append('customEvents', JSON.stringify(customEvents)) + formData.append('name', name) + + if (defaultView?.id) { + formData.append('intent', 'update-project-view') + formData.append('viewId', defaultView.id) + } else { + formData.append('intent', 'create-project-view') + formData.append('type', 'traffic') } - setIsViewSubmitting(false) - onSubmit() - closeModal() + fetcher.submit(formData, { method: 'POST' }) } return ( diff --git a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx index ab201fff1..ec4d03101 100644 --- a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx +++ b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx @@ -23,21 +23,13 @@ import { import { marked } from 'marked' import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useSearchParams } from 'react-router' +import { useSearchParams, useFetcher } from 'react-router' import sanitizeHtml from 'sanitize-html' import { toast } from 'sonner' import { useStickToBottom } from 'use-stick-to-bottom' -import { - askAI, - getRecentAIChats, - getAllAIChats, - getAIChat, - createAIChat, - updateAIChat, - deleteAIChat, - AIChatSummary, -} from '~/api' +import { askAI, AIChatSummary } from '~/api' +import { ProjectViewActionData } from '~/routes/projects.$id' import SwetrixLogo from '~/ui/icons/SwetrixLogo' import Modal from '~/ui/Modal' import { Text } from '~/ui/Text' @@ -474,6 +466,16 @@ const getSuggestionPrompts = (t: any) => [ t('project.askAi.suggestions.deviceTypesChart'), ] +interface AIChat { + id: string + name: string | null + messages: { role: 'user' | 'assistant'; content: string }[] + isOwner?: boolean + branched?: boolean + created: string + updated: string +} + const AskAIView = ({ projectId }: AskAIViewProps) => { const { t } = useTranslation('common') const [searchParams, setSearchParams] = useSearchParams() @@ -490,11 +492,18 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { const [recentChats, setRecentChats] = useState([]) const [allChats, setAllChats] = useState([]) const [allChatsTotal, setAllChatsTotal] = useState(0) - const [isLoadingChats, setIsLoadingChats] = useState(false) const [isViewAllModalOpen, setIsViewAllModalOpen] = useState(false) const [chatToDelete, setChatToDelete] = useState(null) const hasInitializedRef = useRef(false) + const recentChatsFetcher = useFetcher() + const allChatsFetcher = useFetcher() + const loadChatFetcher = useFetcher() + const saveChatFetcher = useFetcher() + const deleteChatFetcher = useFetcher() + + const isLoadingChats = allChatsFetcher.state !== 'idle' + const { scrollRef, contentRef, isAtBottom, scrollToBottom } = useStickToBottom({ resize: 'smooth', initial: 'smooth', @@ -508,39 +517,85 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { const generateMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` - const loadRecentChats = useCallback(async () => { - try { - const chats = await getRecentAIChats(projectId, 3) - setRecentChats(chats) - } catch (err) { - console.error('Failed to load recent chats:', err) + const loadRecentChats = useCallback(() => { + if (recentChatsFetcher.state !== 'idle') return + + const formData = new FormData() + formData.append('intent', 'get-recent-ai-chats') + formData.append('limit', '3') + + recentChatsFetcher.submit(formData, { method: 'POST' }) + }, [recentChatsFetcher]) + + // Handle recent chats fetcher response + useEffect(() => { + if (recentChatsFetcher.state === 'idle' && recentChatsFetcher.data) { + if (recentChatsFetcher.data.success && recentChatsFetcher.data.data) { + setRecentChats(recentChatsFetcher.data.data as AIChatSummary[]) + } } - }, [projectId]) + }, [recentChatsFetcher.state, recentChatsFetcher.data]) const loadAllChats = useCallback( - async (skip: number = 0) => { - setIsLoadingChats(true) - try { - const result = await getAllAIChats(projectId, skip, 20) - if (skip === 0) { + (skip: number = 0) => { + if (allChatsFetcher.state !== 'idle') return + + const formData = new FormData() + formData.append('intent', 'get-all-ai-chats') + formData.append('skip', skip.toString()) + formData.append('take', '20') + + allChatsFetcher.submit(formData, { method: 'POST' }) + }, + [allChatsFetcher], + ) + + // Handle all chats fetcher response + const [pendingAllChatsSkip, setPendingAllChatsSkip] = useState(null) + + useEffect(() => { + if (allChatsFetcher.state === 'idle' && allChatsFetcher.data) { + if (allChatsFetcher.data.success && allChatsFetcher.data.data) { + const result = allChatsFetcher.data.data as { chats: AIChatSummary[]; total: number } + if (pendingAllChatsSkip === 0) { setAllChats(result.chats) } else { setAllChats((prev) => [...prev, ...result.chats]) } setAllChatsTotal(result.total) - } catch (err) { - console.error('Failed to load all chats:', err) - } finally { - setIsLoadingChats(false) } + setPendingAllChatsSkip(null) + } + }, [allChatsFetcher.state, allChatsFetcher.data, pendingAllChatsSkip]) + + const loadAllChatsWithSkip = useCallback( + (skip: number) => { + setPendingAllChatsSkip(skip) + loadAllChats(skip) }, - [projectId], + [loadAllChats], ) const loadChat = useCallback( - async (chatId: string) => { - try { - const chat = await getAIChat(projectId, chatId) + (chatId: string) => { + if (loadChatFetcher.state !== 'idle') return + + const formData = new FormData() + formData.append('intent', 'get-ai-chat') + formData.append('chatId', chatId) + + loadChatFetcher.submit(formData, { method: 'POST' }) + }, + [loadChatFetcher], + ) + + // Handle load chat fetcher response + const [pendingChatId, setPendingChatId] = useState(null) + + useEffect(() => { + if (loadChatFetcher.state === 'idle' && loadChatFetcher.data) { + if (loadChatFetcher.data.success && loadChatFetcher.data.data) { + const chat = loadChatFetcher.data.data as AIChat setMessages( chat.messages.map((m) => ({ id: generateMessageId(), @@ -548,19 +603,45 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { content: m.content, })), ) - setCurrentChatId(chatId) - } catch (err) { - console.error('Failed to load chat:', err) + setCurrentChatId(chat.id) + } else if (loadChatFetcher.data.error) { + console.error('Failed to load chat:', loadChatFetcher.data.error) const newParams = new URLSearchParams(searchParams) newParams.delete('chat') setSearchParams(newParams) } + setPendingChatId(null) + } + }, [loadChatFetcher.state, loadChatFetcher.data, searchParams, setSearchParams]) + + const loadChatById = useCallback( + (chatId: string) => { + setPendingChatId(chatId) + loadChat(chatId) }, - [projectId, searchParams, setSearchParams], + [loadChat], ) + // Handle save chat fetcher response + useEffect(() => { + if (saveChatFetcher.state === 'idle' && saveChatFetcher.data) { + if (saveChatFetcher.data.success && saveChatFetcher.data.data) { + const result = saveChatFetcher.data.data as AIChat + if (result.branched || !currentChatId) { + setCurrentChatId(result.id) + const newParams = new URLSearchParams(searchParams) + newParams.set('chat', result.id) + setSearchParams(newParams, { replace: true }) + } + loadRecentChats() + } else if (saveChatFetcher.data.error) { + console.error('Failed to save chat:', saveChatFetcher.data.error) + } + } + }, [saveChatFetcher.state, saveChatFetcher.data, currentChatId, searchParams, setSearchParams, loadRecentChats]) + const saveChat = useCallback( - async (messagesToSave: Message[]) => { + (messagesToSave: Message[]) => { const apiMessages = messagesToSave .filter((m) => m.content.trim().length > 0) .map((m) => ({ @@ -570,30 +651,19 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { if (apiMessages.length === 0) return - try { - if (currentChatId) { - const result = await updateAIChat(projectId, currentChatId, { messages: apiMessages }) - - // If the chat was branched (user didn't own the original), update to the new chat ID - if (result.branched) { - setCurrentChatId(result.id) - const newParams = new URLSearchParams(searchParams) - newParams.set('chat', result.id) - setSearchParams(newParams, { replace: true }) - } - } else { - const chat = await createAIChat(projectId, apiMessages) - setCurrentChatId(chat.id) - const newParams = new URLSearchParams(searchParams) - newParams.set('chat', chat.id) - setSearchParams(newParams, { replace: true }) - } - loadRecentChats() - } catch (err) { - console.error('Failed to save chat:', err) + const formData = new FormData() + formData.append('messages', JSON.stringify(apiMessages)) + + if (currentChatId) { + formData.append('intent', 'update-ai-chat') + formData.append('chatId', currentChatId) + } else { + formData.append('intent', 'create-ai-chat') } + + saveChatFetcher.submit(formData, { method: 'POST' }) }, - [projectId, currentChatId, searchParams, setSearchParams, loadRecentChats], + [saveChatFetcher, currentChatId], ) useEffect(() => { @@ -602,12 +672,12 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { const chatId = searchParams.get('chat') if (chatId) { - loadChat(chatId) + loadChatById(chatId) } loadRecentChats() - }, [searchParams, loadChat, loadRecentChats]) + }, [searchParams, loadChatById, loadRecentChats]) - const handleNewChat = () => { + const handleNewChat = useCallback(() => { setMessages([]) setCurrentChatId(null) setStreamingMessage(null) @@ -615,34 +685,59 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { const newParams = new URLSearchParams(searchParams) newParams.delete('chat') setSearchParams(newParams) - } + }, [searchParams, setSearchParams]) const handleOpenChat = (chatId: string) => { setIsViewAllModalOpen(false) - loadChat(chatId) + loadChatById(chatId) const newParams = new URLSearchParams(searchParams) newParams.set('chat', chatId) setSearchParams(newParams) } - const handleDeleteChat = async (chatId: string) => { - try { - await deleteAIChat(projectId, chatId) - toast.success(t('project.askAi.chatDeleted')) + // Handle delete chat fetcher response + const [deletingChatId, setDeletingChatId] = useState(null) - if (chatId === currentChatId) { - handleNewChat() - } + useEffect(() => { + if (deleteChatFetcher.state === 'idle' && deleteChatFetcher.data) { + if (deleteChatFetcher.data.success) { + toast.success(t('project.askAi.chatDeleted')) + + if (deletingChatId === currentChatId) { + handleNewChat() + } - loadRecentChats() - if (isViewAllModalOpen) { - loadAllChats(0) + loadRecentChats() + if (isViewAllModalOpen) { + loadAllChatsWithSkip(0) + } + } else if (deleteChatFetcher.data.error) { + console.error('Failed to delete chat:', deleteChatFetcher.data.error) } - } catch (err) { - console.error('Failed to delete chat:', err) - } finally { + setDeletingChatId(null) setChatToDelete(null) } + }, [ + deleteChatFetcher.state, + deleteChatFetcher.data, + deletingChatId, + currentChatId, + isViewAllModalOpen, + loadRecentChats, + loadAllChatsWithSkip, + t, + ]) + + const handleDeleteChat = (chatId: string) => { + if (deleteChatFetcher.state !== 'idle') return + + setDeletingChatId(chatId) + + const formData = new FormData() + formData.append('intent', 'delete-ai-chat') + formData.append('chatId', chatId) + + deleteChatFetcher.submit(formData, { method: 'POST' }) } const handleSubmit = async (e?: React.FormEvent) => { @@ -1060,7 +1155,7 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { type='button' onClick={() => { setIsViewAllModalOpen(true) - loadAllChats(0) + loadAllChatsWithSkip(0) }} className='text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300' > @@ -1134,7 +1229,7 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { {allChats.length < allChatsTotal ? ( - + + + + +
) : null}
diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index cf8369ae2..d7ee86bc5 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -96,9 +96,7 @@ interface TrafficViewProps { // Segment/View props projectViews: ProjectView[] projectViewsLoading: boolean | null - projectViewDeleting: boolean - loadProjectViews: () => Promise - onProjectViewDelete: (viewId: string) => Promise + loadProjectViews: (forced?: boolean) => void setProjectViewToUpdate: (view: ProjectView | undefined) => void setIsAddAViewOpened: (value: boolean) => void onCustomMetric: (metrics: ProjectViewCustomEvent[]) => void @@ -112,9 +110,7 @@ const TrafficView = ({ mode, projectViews, projectViewsLoading, - projectViewDeleting, loadProjectViews, - onProjectViewDelete, setProjectViewToUpdate, setIsAddAViewOpened, onCustomMetric, @@ -861,9 +857,7 @@ const TrafficView = ({ { ] } -// @ts-expect-error SitemapFunction does not support async, investigate it later export const sitemap: SitemapFunction = async () => { const files = await getSitemapClient() diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index 5c2d428d2..a30008ad5 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -232,6 +232,272 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } + // Annotations + case 'get-annotations': { + const password = formData.get('password')?.toString() || '' + + const result = await serverFetch(request, `project/annotations/${projectId}`, { + method: 'GET', + headers: password ? { 'x-password': password } : undefined, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'create-annotation': { + const date = formData.get('date')?.toString() || '' + const text = formData.get('text')?.toString() || '' + + const result = await serverFetch(request, 'project/annotation', { + method: 'POST', + body: { pid: projectId, date, text }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-annotation': { + const annotationId = formData.get('annotationId')?.toString() || '' + const date = formData.get('date')?.toString() || '' + const text = formData.get('text')?.toString() || '' + + const result = await serverFetch(request, 'project/annotation', { + method: 'PATCH', + body: { id: annotationId, pid: projectId, date, text }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-annotation': { + const annotationId = formData.get('annotationId')?.toString() + + const result = await serverFetch(request, `project/annotation/${annotationId}/${projectId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Project Views + case 'get-project-views': { + const password = formData.get('password')?.toString() || '' + + const result = await serverFetch(request, `project/${projectId}/views`, { + method: 'GET', + headers: password ? { 'x-password': password } : undefined, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'create-project-view': { + const name = formData.get('name')?.toString() || '' + const type = formData.get('type')?.toString() || 'traffic' + const filters = JSON.parse(formData.get('filters')?.toString() || '[]') + const customEvents = JSON.parse(formData.get('customEvents')?.toString() || '[]') + + const result = await serverFetch(request, `project/${projectId}/views`, { + method: 'POST', + body: { name, type, filters, customEvents }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-project-view': { + const viewId = formData.get('viewId')?.toString() + const name = formData.get('name')?.toString() || '' + const filters = JSON.parse(formData.get('filters')?.toString() || '[]') + const customEvents = JSON.parse(formData.get('customEvents')?.toString() || '[]') + + const result = await serverFetch(request, `project/${projectId}/views/${viewId}`, { + method: 'PATCH', + body: { name, filters, customEvents }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-project-view': { + const viewId = formData.get('viewId')?.toString() + + const result = await serverFetch(request, `project/${projectId}/views/${viewId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // AI Chat + case 'get-recent-ai-chats': { + const limit = Number(formData.get('limit') || '5') + + const result = await serverFetch(request, `ai/${projectId}/chats?limit=${limit}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-all-ai-chats': { + const skip = Number(formData.get('skip') || '0') + const take = Number(formData.get('take') || '20') + + const result = await serverFetch(request, `ai/${projectId}/chats/all?skip=${skip}&take=${take}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-ai-chat': { + const chatId = formData.get('chatId')?.toString() + + const result = await serverFetch(request, `ai/${projectId}/chats/${chatId}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'create-ai-chat': { + const messages = JSON.parse(formData.get('messages')?.toString() || '[]') + const name = formData.get('name')?.toString() + + const result = await serverFetch(request, `ai/${projectId}/chats`, { + method: 'POST', + body: { messages, name }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-ai-chat': { + const chatId = formData.get('chatId')?.toString() + const messages = formData.get('messages') ? JSON.parse(formData.get('messages')!.toString()) : undefined + const name = formData.get('name')?.toString() + + const body: Record = {} + if (messages) body.messages = messages + if (name !== undefined) body.name = name + + const result = await serverFetch(request, `ai/${projectId}/chats/${chatId}`, { + method: 'POST', + body, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-ai-chat': { + const chatId = formData.get('chatId')?.toString() + + const result = await serverFetch(request, `ai/${projectId}/chats/${chatId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } From 4afc836fd0c1aaa3dade2e974e9d4f219b2c2bba Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 7 Jan 2026 01:03:35 +0000 Subject: [PATCH 16/57] migrate experiments, feature flags & goals --- web/app/api/index.ts | 122 ---- .../tabs/Experiments/ExperimentsView.tsx | 189 +++-- .../FeatureFlags/FeatureFlagSettingsModal.tsx | 147 ++-- .../tabs/FeatureFlags/FeatureFlagsView.tsx | 341 +++++---- .../Project/tabs/Goals/GoalSettingsModal.tsx | 28 +- web/app/routes/projects.$id.tsx | 674 +++++++++++++++++- 6 files changed, 1049 insertions(+), 452 deletions(-) diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 3ee93d523..fd10e762d 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -786,15 +786,6 @@ export const getGoal = (goalId: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export type CreateGoal = { - pid: string - name: string - type: 'pageview' | 'custom_event' - matchType: 'exact' | 'contains' - value?: string - metadataFilters?: { key: string; value: string }[] -} - export const getGoalStats = (goalId: string, period: string, from: string = '', to: string = '', timezone?: string) => api .get(`/goal/${goalId}/stats`, { @@ -849,16 +840,6 @@ export interface FeatureFlagStats { truePercentage: number } -export type CreateFeatureFlag = { - pid: string - key: string - description?: string - flagType?: 'boolean' | 'rollout' - rolloutPercentage?: number - targetingRules?: TargetingRule[] - enabled?: boolean -} - export const DEFAULT_FEATURE_FLAGS_TAKE = 20 export const getProjectFeatureFlags = ( @@ -891,38 +872,6 @@ export const getProjectFeatureFlags = ( }) } -export const getFeatureFlag = (flagId: string) => - api - .get(`/feature-flag/${flagId}`) - .then((response): ProjectFeatureFlag => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const createFeatureFlag = (data: CreateFeatureFlag) => - api - .post('feature-flag', data) - .then((response): ProjectFeatureFlag => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const updateFeatureFlag = (id: string, data: Partial) => - api - .put(`feature-flag/${id}`, data) - .then((response): ProjectFeatureFlag => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const deleteFeatureFlag = (id: string) => - api - .delete(`feature-flag/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getFeatureFlagStats = ( flagId: string, period: string, @@ -1069,36 +1018,6 @@ interface CreateExperiment { export const DEFAULT_EXPERIMENTS_TAKE = 20 -export const getProjectExperiments = ( - projectId: string, - take: number = DEFAULT_EXPERIMENTS_TAKE, - skip = 0, - search?: string, -) => { - const params = new URLSearchParams({ - take: String(take), - skip: String(skip), - }) - - if (search) { - params.set('search', search) - } - - return api - .get(`/experiment/project/${projectId}?${params.toString()}`) - .then( - ( - response, - ): { - results: Experiment[] - total: number - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) -} - export const getExperiment = (experimentId: string) => api .get(`/experiment/${experimentId}`) @@ -1123,38 +1042,6 @@ export const updateExperiment = (id: string, data: Partial) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const deleteExperiment = (id: string) => - api - .delete(`experiment/${id}`) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const startExperiment = (id: string) => - api - .post(`experiment/${id}/start`) - .then((response): Experiment => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const pauseExperiment = (id: string) => - api - .post(`experiment/${id}/pause`) - .then((response): Experiment => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const completeExperiment = (id: string) => - api - .post(`experiment/${id}/complete`) - .then((response): Experiment => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getExperimentResults = ( experimentId: string, period: string, @@ -1680,12 +1567,3 @@ export const getRevenueData = async ( throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message }) } - -// Blog Sitemap (kept client-side for SitemapFunction - no request object available) -export const getSitemap = () => - api - .get('v1/blog/sitemap') - .then((response): (string | string[])[] => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) diff --git a/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx b/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx index af853376d..322d03154 100644 --- a/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx +++ b/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx @@ -17,22 +17,14 @@ import { } from 'lucide-react' import { useState, useEffect, useMemo, useRef, useCallback, memo } from 'react' import { useTranslation } from 'react-i18next' -import { useSearchParams } from 'react-router' +import { useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' -import { - deleteExperiment as deleteExperimentApi, - getProjectExperiments, - startExperiment as startExperimentApi, - pauseExperiment as pauseExperimentApi, - completeExperiment as completeExperimentApi, - DEFAULT_EXPERIMENTS_TAKE, - type Experiment, - type ExperimentStatus, -} from '~/api' +import { DEFAULT_EXPERIMENTS_TAKE, type Experiment, type ExperimentStatus } from '~/api' import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' import { useCurrentProject } from '~/providers/CurrentProjectProvider' +import type { ProjectViewActionData } from '~/routes/projects.$id' import Button from '~/ui/Button' import Spin from '~/ui/icons/Spin' import Loader from '~/ui/Loader' @@ -290,6 +282,8 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi const [searchParams] = useSearchParams() const isEmbedded = searchParams.get('embedded') === 'true' const { t } = useTranslation() + const listFetcher = useFetcher() + const actionFetcher = useFetcher() const [isLoading, setIsLoading] = useState(null) const isLoadingRef = useRef(false) @@ -299,6 +293,7 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi const [page, setPage] = useState(1) const [error, setError] = useState(null) const [filterQuery, setFilterQuery] = useState('') + const processedActionRef = useRef(null) // Modal state const [isModalOpen, setIsModalOpen] = useState(false) @@ -319,50 +314,100 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi const pageAmount = Math.ceil(total / DEFAULT_EXPERIMENTS_TAKE) - const loadExperiments = async (take: number, skip: number, showLoading = true, search?: string) => { - if (isLoadingRef.current) { - return - } - isLoadingRef.current = true + const loadExperiments = useCallback( + (take: number, skip: number, showLoading = true, search?: string) => { + if (isLoadingRef.current) { + return + } + isLoadingRef.current = true - // `showLoading` means "show either initial Loader or refresh LoadingBar". - if (showLoading) { - setIsLoading(true) - } + if (showLoading) { + setIsLoading(true) + } - try { - const result = await getProjectExperiments(id, take, skip, search) + listFetcher.submit( + { + intent: 'get-project-experiments', + take: String(take), + skip: String(skip), + search: search || '', + }, + { method: 'POST' }, + ) + }, + [listFetcher], + ) + + // Handle list fetcher response + useEffect(() => { + if (listFetcher.data?.intent === 'get-project-experiments') { + isLoadingRef.current = false if (isMountedRef.current) { - setExperiments(result.results) - setTotal(result.total) - setError(null) // Clear any previous error on success + setIsLoading(false) + if (listFetcher.data.success && listFetcher.data.data) { + const result = listFetcher.data.data as { results: Experiment[]; total: number } + setExperiments(result.results) + setTotal(result.total) + setError(null) + } else if (listFetcher.data.error) { + setError(listFetcher.data.error) + } } - } catch (reason: any) { - if (isMountedRef.current) { - setError(reason?.message || reason?.toString() || 'Unknown error') + } + }, [listFetcher.data]) + + // Handle action fetcher responses (delete, start, pause, complete) + useEffect(() => { + if (!actionFetcher.data || actionFetcher.state !== 'idle') return + + const actionKey = `${actionFetcher.data.intent}-${JSON.stringify(actionFetcher.data.data)}` + if (processedActionRef.current === actionKey) return + processedActionRef.current = actionKey + + if (actionFetcher.data.intent === 'delete-experiment') { + if (actionFetcher.data.success) { + toast.success(t('experiments.deleted')) + loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) + } else if (actionFetcher.data.error) { + toast.error(actionFetcher.data.error) } - } finally { - isLoadingRef.current = false - if (isMountedRef.current && showLoading) { - setIsLoading(false) + } else if (actionFetcher.data.intent === 'start-experiment') { + if (actionFetcher.data.success) { + toast.success(t('experiments.started')) + loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) + } else if (actionFetcher.data.error) { + toast.error(actionFetcher.data.error) + } + } else if (actionFetcher.data.intent === 'pause-experiment') { + if (actionFetcher.data.success) { + toast.success(t('experiments.paused')) + loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) + } else if (actionFetcher.data.error) { + toast.error(actionFetcher.data.error) + } + } else if (actionFetcher.data.intent === 'complete-experiment') { + if (actionFetcher.data.success) { + toast.success(t('experiments.completed')) + loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) + } else if (actionFetcher.data.error) { + toast.error(actionFetcher.data.error) } } - } + }, [actionFetcher.data, actionFetcher.state, t, loadExperiments, page, filterQuery]) const debouncedLoadExperiments = useMemo( () => _debounce((search: string) => { loadExperiments(DEFAULT_EXPERIMENTS_TAKE, 0, true, search || undefined) }, 300), - // eslint-disable-next-line react-hooks/exhaustive-deps - [id], + [loadExperiments], ) const handleSearchChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value setFilterQuery(value) - setPage(1) // Reset to first page when searching + setPage(1) debouncedLoadExperiments(value) }, [debouncedLoadExperiments], @@ -379,8 +424,6 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi // eslint-disable-next-line react-hooks/exhaustive-deps }, [page]) - // Refresh experiments data when refresh button is clicked. - // If user is viewing results, refresh results instead and defer list refresh until they return. useEffect(() => { if (experimentsRefreshTrigger > 0) { if (viewingResultsId) { @@ -388,14 +431,11 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi setShouldRefreshListOnReturn(true) return } - - // Refresh list (show LoadingBar if we already have data) loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [experimentsRefreshTrigger, viewingResultsId, page]) - // If refresh happened while in results view, refresh list once upon returning. useEffect(() => { if (!viewingResultsId && shouldRefreshListOnReturn) { setShouldRefreshListOnReturn(false) @@ -421,48 +461,39 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi const handleModalSuccess = useCallback(() => { loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, filterQuery]) + }, [page, filterQuery, loadExperiments]) - const handleDeleteExperiment = async (experimentId: string) => { - try { - await deleteExperimentApi(experimentId) - toast.success(t('experiments.deleted')) - loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) - } catch (reason: any) { - toast.error(reason?.response?.data?.message || reason?.message || t('apiNotifications.somethingWentWrong')) - } - } + const handleDeleteExperiment = useCallback( + (experimentId: string) => { + processedActionRef.current = null + actionFetcher.submit({ intent: 'delete-experiment', experimentId }, { method: 'POST' }) + }, + [actionFetcher], + ) - const handleStartExperiment = async (experimentId: string) => { - try { - await startExperimentApi(experimentId) - toast.success(t('experiments.started')) - loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) - } catch (reason: any) { - toast.error(reason?.response?.data?.message || reason?.message || t('apiNotifications.somethingWentWrong')) - } - } + const handleStartExperiment = useCallback( + (experimentId: string) => { + processedActionRef.current = null + actionFetcher.submit({ intent: 'start-experiment', experimentId }, { method: 'POST' }) + }, + [actionFetcher], + ) - const handlePauseExperiment = async (experimentId: string) => { - try { - await pauseExperimentApi(experimentId) - toast.success(t('experiments.paused')) - loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) - } catch (reason: any) { - toast.error(reason?.response?.data?.message || reason?.message || t('apiNotifications.somethingWentWrong')) - } - } + const handlePauseExperiment = useCallback( + (experimentId: string) => { + processedActionRef.current = null + actionFetcher.submit({ intent: 'pause-experiment', experimentId }, { method: 'POST' }) + }, + [actionFetcher], + ) - const handleCompleteExperiment = async (experimentId: string) => { - try { - await completeExperimentApi(experimentId) - toast.success(t('experiments.completed')) - loadExperiments(DEFAULT_EXPERIMENTS_TAKE, (page - 1) * DEFAULT_EXPERIMENTS_TAKE, true, filterQuery || undefined) - } catch (reason: any) { - toast.error(reason?.response?.data?.message || reason?.message || t('apiNotifications.somethingWentWrong')) - } - } + const handleCompleteExperiment = useCallback( + (experimentId: string) => { + processedActionRef.current = null + actionFetcher.submit({ intent: 'complete-experiment', experimentId }, { method: 'POST' }) + }, + [actionFetcher], + ) const handleViewResults = (experimentId: string) => { setViewingResultsId(experimentId) diff --git a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx index 49532a4b2..f895be46a 100644 --- a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx +++ b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx @@ -13,20 +13,15 @@ import { XMarkIcon, ClipboardIcon, CheckIcon as HeroCheckIcon } from '@heroicons import cx from 'clsx' import _map from 'lodash/map' import { Trash2Icon, PlusIcon, CodeIcon, ChevronDownIcon, ChevronsUpDownIcon, CheckIcon } from 'lucide-react' -import { useState, useEffect, useCallback, Fragment } from 'react' +import { useState, useEffect, useCallback, Fragment, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { - getFeatureFlag, - createFeatureFlag, - updateFeatureFlag, - getFilters, - type CreateFeatureFlag, - type TargetingRule, -} from '~/api' +import { getFilters, type ProjectFeatureFlag, type TargetingRule } from '~/api' import { API_URL, isSelfhosted } from '~/lib/constants' import { useTheme } from '~/providers/ThemeProvider' +import type { ProjectViewActionData } from '~/routes/projects.$id' import Button from '~/ui/Button' import Checkbox from '~/ui/Checkbox' import FilterValueInput, { filterCategoryIcons } from '~/ui/FilterValueInput' @@ -64,9 +59,10 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI } = useTranslation() const { theme } = useTheme() const isNew = !flagId + const fetcher = useFetcher() + const processedRef = useRef(null) const [isLoading, setIsLoading] = useState(false) - const [isSaving, setIsSaving] = useState(false) const [showImplementation, setShowImplementation] = useState(false) const [copiedCode, setCopiedCode] = useState(null) @@ -82,6 +78,8 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI const [targetingRules, setTargetingRules] = useState([]) const [enabled, setEnabled] = useState(true) + const isSaving = fetcher.state === 'submitting' + const resetForm = () => { setKey('') setDescription('') @@ -117,7 +115,6 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI // Pre-fetch filter values when modal opens useEffect(() => { if (isOpen) { - // Pre-fetch values for all targeting columns TARGETING_COLUMNS.forEach(({ value: column }) => { if (!filterValuesCache[column] && !loadingColumns.has(column)) { fetchFilterValues(column) @@ -126,70 +123,96 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI } }, [isOpen, fetchFilterValues, filterValuesCache, loadingColumns]) - const loadFlag = async () => { - if (!flagId) return - setIsLoading(true) - try { - const flag = await getFeatureFlag(flagId) - setKey(flag.key) - setDescription(flag.description || '') - setFlagType(flag.flagType) - setRolloutPercentage(flag.rolloutPercentage) - setTargetingRules(flag.targetingRules || []) - setEnabled(flag.enabled) - } catch (err: any) { - toast.error(err?.message || 'Failed to load feature flag') - onClose() - } finally { + // Load flag via fetcher + useEffect(() => { + if (isOpen && flagId) { + setIsLoading(true) + fetcher.submit({ intent: 'get-feature-flag', flagId }, { method: 'POST' }) + } else if (isOpen && !flagId) { + resetForm() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, flagId]) + + // Handle fetcher responses + useEffect(() => { + if (!fetcher.data || fetcher.state !== 'idle') return + + const responseKey = `${fetcher.data.intent}-${fetcher.data.success}` + if (processedRef.current === responseKey) return + processedRef.current = responseKey + + if (fetcher.data.intent === 'get-feature-flag') { setIsLoading(false) + if (fetcher.data.success && fetcher.data.data) { + const flag = fetcher.data.data as ProjectFeatureFlag + setKey(flag.key) + setDescription(flag.description || '') + setFlagType(flag.flagType) + setRolloutPercentage(flag.rolloutPercentage) + setTargetingRules(flag.targetingRules || []) + setEnabled(flag.enabled) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + onClose() + } + } else if (fetcher.data.intent === 'create-feature-flag') { + if (fetcher.data.success) { + toast.success(t('featureFlags.created')) + onSuccess() + onClose() + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + } else if (fetcher.data.intent === 'update-feature-flag') { + if (fetcher.data.success) { + toast.success(t('featureFlags.updated')) + onSuccess() + onClose() + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } } - } + }, [fetcher.data, fetcher.state, t, onSuccess, onClose]) + // Reset processed ref when modal opens useEffect(() => { if (isOpen) { - if (flagId) { - loadFlag() - } else { - resetForm() - } + processedRef.current = null } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, flagId]) + }, [isOpen]) - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - setIsSaving(true) + processedRef.current = null - try { - if (isNew) { - const data: CreateFeatureFlag = { - pid: projectId, + if (isNew) { + fetcher.submit( + { + intent: 'create-feature-flag', key, - description: description || undefined, + description: description || '', flagType, - rolloutPercentage: flagType === 'rollout' ? rolloutPercentage : 100, - targetingRules: targetingRules.length > 0 ? targetingRules : undefined, - enabled, - } - await createFeatureFlag(data) - toast.success(t('featureFlags.created')) - } else if (flagId) { - await updateFeatureFlag(flagId, { + rolloutPercentage: String(flagType === 'rollout' ? rolloutPercentage : 100), + targetingRules: JSON.stringify(targetingRules.length > 0 ? targetingRules : []), + enabled: String(enabled), + }, + { method: 'POST' }, + ) + } else if (flagId) { + fetcher.submit( + { + intent: 'update-feature-flag', + flagId, key, - description: description || null, + description: description || '', flagType, - rolloutPercentage: flagType === 'rollout' ? rolloutPercentage : 100, - targetingRules: targetingRules.length > 0 ? targetingRules : null, - enabled, - }) - toast.success(t('featureFlags.updated')) - } - onSuccess() - onClose() - } catch (err: any) { - toast.error(err?.message || t('apiNotifications.somethingWentWrong')) - } finally { - setIsSaving(false) + rolloutPercentage: String(flagType === 'rollout' ? rolloutPercentage : 100), + targetingRules: JSON.stringify(targetingRules.length > 0 ? targetingRules : []), + enabled: String(enabled), + }, + { method: 'POST' }, + ) } } diff --git a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx index ec9d507fb..788fd7cd6 100644 --- a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx +++ b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx @@ -22,12 +22,10 @@ import { } from 'lucide-react' import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useLocation, useSearchParams } from 'react-router' +import { Link, useLocation, useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' import { - deleteFeatureFlag as deleteFeatureFlagApi, - getProjectFeatureFlags, getFeatureFlagStats, getFeatureFlagProfiles, DEFAULT_FEATURE_FLAGS_TAKE, @@ -39,6 +37,7 @@ import { import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' import { useCurrentProject } from '~/providers/CurrentProjectProvider' +import type { ProjectViewActionData } from '~/routes/projects.$id' import { Badge } from '~/ui/Badge' import Button from '~/ui/Button' import Spin from '~/ui/icons/Spin' @@ -433,6 +432,9 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags const isEmbedded = searchParams.get('embedded') === 'true' const { t } = useTranslation() + const listFetcher = useFetcher() + const actionFetcher = useFetcher() + const [isLoading, setIsLoading] = useState(null) const isLoadingRef = useRef(false) const isMountedRef = useRef(true) @@ -443,6 +445,7 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags const [page, setPage] = useState(1) const [error, setError] = useState(null) const [filterQuery, setFilterQuery] = useState('') + const processedActionRef = useRef(null) // Expanded flag state const [expandedFlagId, setExpandedFlagId] = useState(null) @@ -465,30 +468,69 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags const pageAmount = Math.ceil(total / DEFAULT_FEATURE_FLAGS_TAKE) - const loadFlags = async (take: number, skip: number, search?: string) => { - if (isLoadingRef.current) { - return - } - isLoadingRef.current = true - setIsLoading(true) - - try { - const result = await getProjectFeatureFlags(id, take, skip, search) - if (isMountedRef.current) { - setFlags(result.results) - setTotal(result.total) + const loadFlags = useCallback( + (take: number, skip: number, search?: string) => { + if (isLoadingRef.current) { + return } - } catch (reason: any) { - if (isMountedRef.current) { - setError(reason?.message || reason?.toString() || 'Unknown error') - } - } finally { + isLoadingRef.current = true + setIsLoading(true) + + listFetcher.submit( + { + intent: 'get-project-feature-flags', + take: String(take), + skip: String(skip), + search: search || '', + }, + { method: 'POST' }, + ) + }, + [listFetcher], + ) + + // Handle list fetcher response + useEffect(() => { + if (listFetcher.data?.intent === 'get-project-feature-flags') { isLoadingRef.current = false if (isMountedRef.current) { setIsLoading(false) + if (listFetcher.data.success && listFetcher.data.data) { + const result = listFetcher.data.data as { results: ProjectFeatureFlag[]; total: number } + setFlags(result.results) + setTotal(result.total) + } else if (listFetcher.data.error) { + setError(listFetcher.data.error) + } } } - } + }, [listFetcher.data]) + + // Handle action fetcher responses (delete, update) + useEffect(() => { + if (!actionFetcher.data || actionFetcher.state !== 'idle') return + + const actionKey = `${actionFetcher.data.intent}-${JSON.stringify(actionFetcher.data.data)}` + if (processedActionRef.current === actionKey) return + processedActionRef.current = actionKey + + if (actionFetcher.data.intent === 'delete-feature-flag') { + if (actionFetcher.data.success) { + toast.success(t('featureFlags.deleted')) + loadFlags(DEFAULT_FEATURE_FLAGS_TAKE, (page - 1) * DEFAULT_FEATURE_FLAGS_TAKE, filterQuery || undefined) + } else if (actionFetcher.data.error) { + toast.error(actionFetcher.data.error) + } + } else if (actionFetcher.data.intent === 'update-feature-flag') { + if (actionFetcher.data.success && actionFetcher.data.data) { + const updated = actionFetcher.data.data as ProjectFeatureFlag + toast.success(updated.enabled ? t('featureFlags.flagEnabled') : t('featureFlags.flagDisabled')) + setFlags((prev) => prev.map((f) => (f.id === updated.id ? updated : f))) + } else if (actionFetcher.data.error) { + toast.error(actionFetcher.data.error) + } + } + }, [actionFetcher.data, actionFetcher.state, t, loadFlags, page, filterQuery]) // Debounced search to avoid excessive API calls const debouncedLoadFlags = useMemo( @@ -496,8 +538,7 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags _debounce((search: string) => { loadFlags(DEFAULT_FEATURE_FLAGS_TAKE, 0, search || undefined) }, 300), - // eslint-disable-next-line react-hooks/exhaustive-deps - [id], + [loadFlags], ) // Handle search input change @@ -518,145 +559,99 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags } }, [debouncedLoadFlags]) - const loadFlagStats = async (flagId: string) => { - setStatsLoading((prev) => ({ ...prev, [flagId]: true })) - try { - const stats = await getFeatureFlagStats(flagId, period, from, to, timezone) - if (isMountedRef.current) { - setFlagStats((prev) => ({ ...prev, [flagId]: stats })) - } - } catch (err) { - console.error('Failed to load flag stats:', err) - if (isMountedRef.current) { - setFlagStats((prev) => ({ ...prev, [flagId]: null })) - } - } finally { - if (isMountedRef.current) { - setStatsLoading((prev) => ({ ...prev, [flagId]: false })) + const loadFlagStats = useCallback( + async (flagId: string) => { + setStatsLoading((prev) => ({ ...prev, [flagId]: true })) + try { + const stats = await getFeatureFlagStats(flagId, period, from, to, timezone) + if (isMountedRef.current) { + setFlagStats((prev) => ({ ...prev, [flagId]: stats })) + } + } catch (err) { + console.error('Failed to load flag stats:', err) + if (isMountedRef.current) { + setFlagStats((prev) => ({ ...prev, [flagId]: null })) + } + } finally { + if (isMountedRef.current) { + setStatsLoading((prev) => ({ ...prev, [flagId]: false })) + } } - } - } + }, + [period, from, to, timezone], + ) - const loadFlagProfiles = async (flagId: string, append = false, resultFilter?: boolean) => { - const currentProfiles = append ? flagProfiles[flagId] || [] : [] - const skip = append ? currentProfiles.length : 0 - const filter = resultFilter ?? flagResultFilters[flagId] ?? true - const apiFilter = filter ? 'true' : 'false' - - setProfilesLoading((prev) => ({ ...prev, [flagId]: true })) - try { - const result = await getFeatureFlagProfiles( - flagId, - period, - from, - to, - timezone, - DEFAULT_FEATURE_FLAG_PROFILES_TAKE, - skip, - apiFilter, - ) - if (isMountedRef.current) { - setFlagProfiles((prev) => ({ - ...prev, - [flagId]: append ? [...currentProfiles, ...result.profiles] : result.profiles, - })) - setFlagProfilesTotal((prev) => ({ ...prev, [flagId]: result.total })) - } - } catch (err) { - console.error('Failed to load flag profiles:', err) - if (isMountedRef.current) { - setFlagProfiles((prev) => ({ ...prev, [flagId]: append ? currentProfiles : [] })) - setFlagProfilesTotal((prev) => ({ ...prev, [flagId]: 0 })) - } - } finally { - if (isMountedRef.current) { - setProfilesLoading((prev) => ({ ...prev, [flagId]: false })) + const loadFlagProfiles = useCallback( + async (flagId: string, append = false, resultFilter?: boolean) => { + const currentProfiles = append ? flagProfiles[flagId] || [] : [] + const skip = append ? currentProfiles.length : 0 + const filter = resultFilter ?? flagResultFilters[flagId] ?? true + const apiFilter = filter ? 'true' : 'false' + + setProfilesLoading((prev) => ({ ...prev, [flagId]: true })) + try { + const result = await getFeatureFlagProfiles( + flagId, + period, + from, + to, + timezone, + DEFAULT_FEATURE_FLAG_PROFILES_TAKE, + skip, + apiFilter, + ) + if (isMountedRef.current) { + setFlagProfiles((prev) => ({ + ...prev, + [flagId]: append ? [...currentProfiles, ...result.profiles] : result.profiles, + })) + setFlagProfilesTotal((prev) => ({ ...prev, [flagId]: result.total })) + } + } catch (err) { + console.error('Failed to load flag profiles:', err) + if (isMountedRef.current) { + setFlagProfiles((prev) => ({ ...prev, [flagId]: append ? currentProfiles : [] })) + setFlagProfilesTotal((prev) => ({ ...prev, [flagId]: 0 })) + } + } finally { + if (isMountedRef.current) { + setProfilesLoading((prev) => ({ ...prev, [flagId]: false })) + } } - } - } + }, + [period, from, to, timezone, flagProfiles, flagResultFilters], + ) - const handleToggleExpand = async (flagId: string) => { - if (expandedFlagId === flagId) { - // Collapse if already expanded - setExpandedFlagId(null) - } else { - // Expand and load profiles if not already loaded - setExpandedFlagId(flagId) - if (!flagProfiles[flagId] && !profilesLoading[flagId]) { - // First try loading with true filter (served) - setProfilesLoading((prev) => ({ ...prev, [flagId]: true })) - try { - const trueResult = await getFeatureFlagProfiles( - flagId, - period, - from, - to, - timezone, - DEFAULT_FEATURE_FLAG_PROFILES_TAKE, - 0, - 'true', - ) - - if (trueResult.total > 0) { - // Has true results, use them - if (isMountedRef.current) { - setFlagProfiles((prev) => ({ ...prev, [flagId]: trueResult.profiles })) - setFlagProfilesTotal((prev) => ({ ...prev, [flagId]: trueResult.total })) - setFlagResultFilters((prev) => ({ ...prev, [flagId]: true })) - } - } else { - // No true results, try false - const falseResult = await getFeatureFlagProfiles( - flagId, - period, - from, - to, - timezone, - DEFAULT_FEATURE_FLAG_PROFILES_TAKE, - 0, - 'false', - ) - - if (isMountedRef.current) { - if (falseResult.total > 0) { - // Has false results, use them and set filter to false - setFlagProfiles((prev) => ({ ...prev, [flagId]: falseResult.profiles })) - setFlagProfilesTotal((prev) => ({ ...prev, [flagId]: falseResult.total })) - setFlagResultFilters((prev) => ({ ...prev, [flagId]: false })) - } else { - // No results at all, default to true filter with empty - setFlagProfiles((prev) => ({ ...prev, [flagId]: [] })) - setFlagProfilesTotal((prev) => ({ ...prev, [flagId]: 0 })) - setFlagResultFilters((prev) => ({ ...prev, [flagId]: true })) - } - } - } - } catch (err) { - console.error('Failed to load flag profiles:', err) - if (isMountedRef.current) { - setFlagProfiles((prev) => ({ ...prev, [flagId]: [] })) - setFlagProfilesTotal((prev) => ({ ...prev, [flagId]: 0 })) - setFlagResultFilters((prev) => ({ ...prev, [flagId]: true })) - } - } finally { - if (isMountedRef.current) { - setProfilesLoading((prev) => ({ ...prev, [flagId]: false })) - } + const handleToggleExpand = useCallback( + (flagId: string) => { + if (expandedFlagId === flagId) { + setExpandedFlagId(null) + } else { + setExpandedFlagId(flagId) + if (!flagProfiles[flagId] && !profilesLoading[flagId]) { + setFlagResultFilters((prev) => ({ ...prev, [flagId]: true })) + loadFlagProfiles(flagId, false, true) } } - } - } + }, + [expandedFlagId, flagProfiles, profilesLoading, loadFlagProfiles], + ) - const handleLoadMoreProfiles = (flagId: string) => { - loadFlagProfiles(flagId, true) - } + const handleLoadMoreProfiles = useCallback( + (flagId: string) => { + loadFlagProfiles(flagId, true) + }, + [loadFlagProfiles], + ) - const handleResultFilterChange = (flagId: string, filter: boolean) => { - setFlagResultFilters((prev) => ({ ...prev, [flagId]: filter })) - // Reset profiles and reload with new filter - setFlagProfiles((prev) => ({ ...prev, [flagId]: [] })) - loadFlagProfiles(flagId, false, filter) - } + const handleResultFilterChange = useCallback( + (flagId: string, filter: boolean) => { + setFlagResultFilters((prev) => ({ ...prev, [flagId]: filter })) + setFlagProfiles((prev) => ({ ...prev, [flagId]: [] })) + loadFlagProfiles(flagId, false, filter) + }, + [loadFlagProfiles], + ) useEffect(() => { loadFlags(DEFAULT_FEATURE_FLAGS_TAKE, (page - 1) * DEFAULT_FEATURE_FLAGS_TAKE, filterQuery || undefined) @@ -667,9 +662,7 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags useEffect(() => { if (featureFlagsRefreshTrigger > 0) { loadFlags(DEFAULT_FEATURE_FLAGS_TAKE, (page - 1) * DEFAULT_FEATURE_FLAGS_TAKE, filterQuery || undefined) - // Clear cached stats data to force reload setFlagStats({}) - // Refresh profiles for expanded flag if (expandedFlagId) { setFlagProfiles((prev) => ({ [expandedFlagId]: prev[expandedFlagId] })) loadFlagProfiles(expandedFlagId) @@ -682,14 +675,12 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags }, [featureFlagsRefreshTrigger]) useEffect(() => { - // Load stats for all flags when flags change or date range changes flags.forEach((flag) => { loadFlagStats(flag.id) }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [flags, period, from, to, timezone]) - // Reload profiles when period changes for expanded flag useEffect(() => { if (expandedFlagId && flagProfiles[expandedFlagId]) { loadFlagProfiles(expandedFlagId) @@ -716,27 +707,21 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags loadFlags(DEFAULT_FEATURE_FLAGS_TAKE, (page - 1) * DEFAULT_FEATURE_FLAGS_TAKE, filterQuery || undefined) } - const handleDeleteFlag = async (flagId: string) => { - try { - await deleteFeatureFlagApi(flagId) - toast.success(t('featureFlags.deleted')) - loadFlags(DEFAULT_FEATURE_FLAGS_TAKE, (page - 1) * DEFAULT_FEATURE_FLAGS_TAKE, filterQuery || undefined) - } catch (reason: any) { - toast.error(reason?.response?.data?.message || reason?.message || t('apiNotifications.somethingWentWrong')) - } - } + const handleDeleteFlag = useCallback( + (flagId: string) => { + processedActionRef.current = null + actionFetcher.submit({ intent: 'delete-feature-flag', flagId }, { method: 'POST' }) + }, + [actionFetcher], + ) - const handleToggleFlag = async (flagId: string, enabled: boolean) => { - try { - const { updateFeatureFlag } = await import('~/api') - await updateFeatureFlag(flagId, { enabled }) - toast.success(enabled ? t('featureFlags.flagEnabled') : t('featureFlags.flagDisabled')) - // Update local state - setFlags((prev) => prev.map((f) => (f.id === flagId ? { ...f, enabled } : f))) - } catch (reason: any) { - toast.error(reason?.response?.data?.message || reason?.message || t('apiNotifications.somethingWentWrong')) - } - } + const handleToggleFlag = useCallback( + (flagId: string, enabled: boolean) => { + processedActionRef.current = null + actionFetcher.submit({ intent: 'update-feature-flag', flagId, enabled: String(enabled) }, { method: 'POST' }) + }, + [actionFetcher], + ) if (error && isLoading === false) { return ( diff --git a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx index a0cca41c8..88bd4eada 100644 --- a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx @@ -2,7 +2,7 @@ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/re import { XMarkIcon } from '@heroicons/react/24/outline' import _map from 'lodash/map' import { Trash2Icon, PlusIcon } from 'lucide-react' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' @@ -35,6 +35,7 @@ interface GoalSettingsModalProps { const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: GoalSettingsModalProps) => { const { t } = useTranslation() const fetcher = useFetcher() + const processedRef = useRef(null) const isNew = !goalId const [isLoading, setIsLoading] = useState(false) @@ -87,7 +88,13 @@ const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: Go // Handle fetcher responses useEffect(() => { - if (fetcher.data?.success) { + if (!fetcher.data || fetcher.state !== 'idle') return + + const responseKey = `${fetcher.data.intent}-${fetcher.data.success}` + if (processedRef.current === responseKey) return + processedRef.current = responseKey + + if (fetcher.data.success) { const { intent } = fetcher.data if (intent === 'create-goal') { toast.success(t('goals.created')) @@ -96,19 +103,28 @@ const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: Go } onSuccess() onClose() - } else if (fetcher.data?.error) { + } else if (fetcher.data.error) { toast.error(fetcher.data.error) } - }, [fetcher.data, t, onSuccess, onClose]) + }, [fetcher.data, fetcher.state, t, onSuccess, onClose]) + + // Reset processed ref when modal opens + useEffect(() => { + if (isOpen) { + processedRef.current = null + } + }, [isOpen]) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() + processedRef.current = null const formData = new FormData() formData.set('intent', isNew ? 'create-goal' : 'update-goal') formData.set('name', name) - formData.set('goalType', type) - formData.set('goalValue', value) + formData.set('type', type) + formData.set('matchType', matchType) + formData.set('value', value) if (!isNew && goalId) { formData.set('goalId', goalId) diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index a30008ad5..492d991de 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -48,12 +48,13 @@ export async function action({ request, params }: ActionFunctionArgs) { // Goals case 'create-goal': { const name = formData.get('name')?.toString() || '' - const goalType = formData.get('goalType')?.toString() || 'custom' - const goalValue = formData.get('goalValue')?.toString() || '' + const type = formData.get('type')?.toString() || 'pageview' + const matchType = formData.get('matchType')?.toString() || 'exact' + const value = formData.get('value')?.toString() || '' const result = await serverFetch(request, 'goal', { method: 'POST', - body: { pid: projectId, name, goalType, goalValue }, + body: { pid: projectId, name, type, matchType, value }, }) if (result.error) { @@ -69,11 +70,13 @@ export async function action({ request, params }: ActionFunctionArgs) { case 'update-goal': { const goalId = formData.get('goalId')?.toString() const name = formData.get('name')?.toString() - const goalValue = formData.get('goalValue')?.toString() + const type = formData.get('type')?.toString() + const matchType = formData.get('matchType')?.toString() + const value = formData.get('value')?.toString() const result = await serverFetch(request, `goal/${goalId}`, { method: 'PUT', - body: { name, goalValue }, + body: { name, type, matchType, value }, }) if (result.error) { @@ -498,6 +501,667 @@ export async function action({ request, params }: ActionFunctionArgs) { ) } + // Feature Flags + case 'get-project-feature-flags': { + const take = Number(formData.get('take') || '20') + const skip = Number(formData.get('skip') || '0') + const search = formData.get('search')?.toString() || '' + + const params = new URLSearchParams({ take: String(take), skip: String(skip) }) + if (search) params.append('search', search) + + const result = await serverFetch(request, `feature-flag/project/${projectId}?${params.toString()}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-feature-flag': { + const flagId = formData.get('flagId')?.toString() + + const result = await serverFetch(request, `feature-flag/${flagId}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'create-feature-flag': { + const key = formData.get('key')?.toString() || '' + const description = formData.get('description')?.toString() || '' + const flagType = formData.get('flagType')?.toString() || 'boolean' + const rolloutPercentage = Number(formData.get('rolloutPercentage') || '100') + const targetingRules = JSON.parse(formData.get('targetingRules')?.toString() || '[]') + const enabled = formData.get('enabled') === 'true' + + const result = await serverFetch(request, 'feature-flag', { + method: 'POST', + body: { pid: projectId, key, description, flagType, rolloutPercentage, targetingRules, enabled }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-feature-flag': { + const flagId = formData.get('flagId')?.toString() + const key = formData.get('key')?.toString() + const description = formData.get('description')?.toString() + const flagType = formData.get('flagType')?.toString() + const rolloutPercentage = formData.get('rolloutPercentage') + ? Number(formData.get('rolloutPercentage')) + : undefined + const targetingRules = formData.get('targetingRules') + ? JSON.parse(formData.get('targetingRules')!.toString()) + : undefined + const enabled = formData.has('enabled') ? formData.get('enabled') === 'true' : undefined + + const body: Record = {} + if (key !== undefined) body.key = key + if (description !== undefined) body.description = description + if (flagType !== undefined) body.flagType = flagType + if (rolloutPercentage !== undefined) body.rolloutPercentage = rolloutPercentage + if (targetingRules !== undefined) body.targetingRules = targetingRules + if (enabled !== undefined) body.enabled = enabled + + const result = await serverFetch(request, `feature-flag/${flagId}`, { + method: 'PUT', + body, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-feature-flag': { + const flagId = formData.get('flagId')?.toString() + + const result = await serverFetch(request, `feature-flag/${flagId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-feature-flag-stats': { + const flagId = formData.get('flagId')?.toString() + const period = formData.get('period')?.toString() || '7d' + const fromDate = formData.get('from')?.toString() || '' + const toDate = formData.get('to')?.toString() || '' + const tz = formData.get('timezone')?.toString() || '' + + const params = new URLSearchParams({ period }) + if (fromDate) params.append('from', fromDate) + if (toDate) params.append('to', toDate) + if (tz) params.append('timezone', tz) + + const result = await serverFetch(request, `feature-flag/${flagId}/stats?${params.toString()}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: { flagId, stats: result.data } }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-feature-flag-profiles': { + const flagId = formData.get('flagId')?.toString() + const period = formData.get('period')?.toString() || '7d' + const fromDate = formData.get('from')?.toString() || '' + const toDate = formData.get('to')?.toString() || '' + const tz = formData.get('timezone')?.toString() || '' + const take = Number(formData.get('take') || '15') + const skip = Number(formData.get('skip') || '0') + const resultFilter = formData.get('result')?.toString() || '' + + const params = new URLSearchParams({ period, take: String(take), skip: String(skip) }) + if (fromDate) params.append('from', fromDate) + if (toDate) params.append('to', toDate) + if (tz) params.append('timezone', tz) + if (resultFilter && resultFilter !== 'all') params.append('result', resultFilter) + + const result = await serverFetch(request, `feature-flag/${flagId}/profiles?${params.toString()}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + const profilesData = result.data as { profiles: unknown[]; total: number } + return data( + { intent, success: true, data: { flagId, profiles: profilesData.profiles, total: profilesData.total } }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Experiments + case 'get-project-experiments': { + const take = Number(formData.get('take') || '20') + const skip = Number(formData.get('skip') || '0') + const search = formData.get('search')?.toString() || '' + + const params = new URLSearchParams({ take: String(take), skip: String(skip) }) + if (search) params.append('search', search) + + const result = await serverFetch(request, `experiment/project/${projectId}?${params.toString()}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-experiment': { + const experimentId = formData.get('experimentId')?.toString() + + const result = await serverFetch(request, `experiment/${experimentId}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'create-experiment': { + const name = formData.get('name')?.toString() || '' + const description = formData.get('description')?.toString() || '' + const hypothesis = formData.get('hypothesis')?.toString() || '' + const exposureTrigger = formData.get('exposureTrigger')?.toString() || 'feature_flag' + const customEventName = formData.get('customEventName')?.toString() || '' + const multipleVariantHandling = formData.get('multipleVariantHandling')?.toString() || 'exclude' + const filterInternalUsers = formData.get('filterInternalUsers') === 'true' + const featureFlagMode = formData.get('featureFlagMode')?.toString() || 'create' + const featureFlagKey = formData.get('featureFlagKey')?.toString() || '' + const existingFeatureFlagId = formData.get('existingFeatureFlagId')?.toString() || '' + const goalId = formData.get('goalId')?.toString() || '' + const variants = JSON.parse(formData.get('variants')?.toString() || '[]') + + const body: Record = { + pid: projectId, + name, + variants, + } + if (description) body.description = description + if (hypothesis) body.hypothesis = hypothesis + if (exposureTrigger) body.exposureTrigger = exposureTrigger + if (customEventName) body.customEventName = customEventName + if (multipleVariantHandling) body.multipleVariantHandling = multipleVariantHandling + body.filterInternalUsers = filterInternalUsers + if (featureFlagMode) body.featureFlagMode = featureFlagMode + if (featureFlagKey) body.featureFlagKey = featureFlagKey + if (existingFeatureFlagId) body.existingFeatureFlagId = existingFeatureFlagId + if (goalId) body.goalId = goalId + + const result = await serverFetch(request, 'experiment', { + method: 'POST', + body, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'update-experiment': { + const experimentId = formData.get('experimentId')?.toString() + const name = formData.get('name')?.toString() + const description = formData.get('description')?.toString() + const hypothesis = formData.get('hypothesis')?.toString() + const exposureTrigger = formData.get('exposureTrigger')?.toString() + const customEventName = formData.get('customEventName')?.toString() + const multipleVariantHandling = formData.get('multipleVariantHandling')?.toString() + const filterInternalUsers = formData.has('filterInternalUsers') + ? formData.get('filterInternalUsers') === 'true' + : undefined + const featureFlagMode = formData.get('featureFlagMode')?.toString() + const featureFlagKey = formData.get('featureFlagKey')?.toString() + const existingFeatureFlagId = formData.get('existingFeatureFlagId')?.toString() + const goalId = formData.get('goalId')?.toString() + const variants = formData.get('variants') ? JSON.parse(formData.get('variants')!.toString()) : undefined + + const body: Record = {} + if (name !== undefined) body.name = name + if (description !== undefined) body.description = description + if (hypothesis !== undefined) body.hypothesis = hypothesis + if (exposureTrigger !== undefined) body.exposureTrigger = exposureTrigger + if (customEventName !== undefined) body.customEventName = customEventName + if (multipleVariantHandling !== undefined) body.multipleVariantHandling = multipleVariantHandling + if (filterInternalUsers !== undefined) body.filterInternalUsers = filterInternalUsers + if (featureFlagMode !== undefined) body.featureFlagMode = featureFlagMode + if (featureFlagKey !== undefined) body.featureFlagKey = featureFlagKey + if (existingFeatureFlagId !== undefined) body.existingFeatureFlagId = existingFeatureFlagId + if (goalId !== undefined) body.goalId = goalId + if (variants !== undefined) body.variants = variants + + const result = await serverFetch(request, `experiment/${experimentId}`, { + method: 'PUT', + body, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'delete-experiment': { + const experimentId = formData.get('experimentId')?.toString() + + const result = await serverFetch(request, `experiment/${experimentId}`, { + method: 'DELETE', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'start-experiment': { + const experimentId = formData.get('experimentId')?.toString() + + const result = await serverFetch(request, `experiment/${experimentId}/start`, { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'pause-experiment': { + const experimentId = formData.get('experimentId')?.toString() + + const result = await serverFetch(request, `experiment/${experimentId}/pause`, { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'complete-experiment': { + const experimentId = formData.get('experimentId')?.toString() + + const result = await serverFetch(request, `experiment/${experimentId}/complete`, { + method: 'POST', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-experiment-results': { + const experimentId = formData.get('experimentId')?.toString() + const period = formData.get('period')?.toString() || '7d' + const timeBucket = formData.get('timeBucket')?.toString() || 'day' + const fromDate = formData.get('from')?.toString() || '' + const toDate = formData.get('to')?.toString() || '' + const tz = formData.get('timezone')?.toString() || '' + + const params = new URLSearchParams({ period, timeBucket }) + if (fromDate) params.append('from', fromDate) + if (toDate) params.append('to', toDate) + if (tz) params.append('timezone', tz) + + const result = await serverFetch(request, `experiment/${experimentId}/results?${params.toString()}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Goals (getters) + case 'get-project-goals': { + const take = Number(formData.get('take') || '20') + const skip = Number(formData.get('skip') || '0') + const search = formData.get('search')?.toString() || '' + + const params = new URLSearchParams({ take: String(take), skip: String(skip) }) + if (search) params.append('search', search) + + const result = await serverFetch(request, `goal/project/${projectId}?${params.toString()}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-goal': { + const goalId = formData.get('goalId')?.toString() + + const result = await serverFetch(request, `goal/${goalId}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-goal-stats': { + const goalId = formData.get('goalId')?.toString() + const period = formData.get('period')?.toString() || '7d' + const fromDate = formData.get('from')?.toString() || '' + const toDate = formData.get('to')?.toString() || '' + const tz = formData.get('timezone')?.toString() || '' + + const params = new URLSearchParams({ period }) + if (fromDate) params.append('from', fromDate) + if (toDate) params.append('to', toDate) + if (tz) params.append('timezone', tz) + + const result = await serverFetch(request, `goal/${goalId}/stats?${params.toString()}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-goal-chart': { + const goalId = formData.get('goalId')?.toString() + const period = formData.get('period')?.toString() || '7d' + const fromDate = formData.get('from')?.toString() || '' + const toDate = formData.get('to')?.toString() || '' + const timeBucket = formData.get('timeBucket')?.toString() || 'day' + const tz = formData.get('timezone')?.toString() || '' + + const params = new URLSearchParams({ period, timeBucket }) + if (fromDate) params.append('from', fromDate) + if (toDate) params.append('to', toDate) + if (tz) params.append('timezone', tz) + + const result = await serverFetch(request, `goal/${goalId}/chart?${params.toString()}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Funnels (getters) + case 'get-funnels': { + const password = formData.get('password')?.toString() || '' + + const result = await serverFetch(request, `project/funnels/${projectId}`, { + method: 'GET', + headers: password ? { 'x-password': password } : undefined, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-funnel-data': { + const period = formData.get('period')?.toString() || '' + const fromDate = formData.get('from')?.toString() || '' + const toDate = formData.get('to')?.toString() || '' + const tz = formData.get('timezone')?.toString() || '' + const funnelId = formData.get('funnelId')?.toString() || '' + const password = formData.get('password')?.toString() || '' + + const params = new URLSearchParams({ pid: projectId || '' }) + if (period) params.append('period', period) + if (fromDate) params.append('from', fromDate) + if (toDate) params.append('to', toDate) + if (tz) params.append('timezone', tz) + if (funnelId) params.append('funnelId', funnelId) + + const result = await serverFetch(request, `log/funnel?${params.toString()}`, { + method: 'GET', + headers: password ? { 'x-password': password } : undefined, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Alerts (getters) + case 'get-project-alerts': { + const take = Number(formData.get('take') || '25') + const skip = Number(formData.get('skip') || '0') + + const result = await serverFetch(request, `alert/project/${projectId}?take=${take}&skip=${skip}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-alert': { + const alertId = formData.get('alertId')?.toString() + + const result = await serverFetch(request, `alert/${alertId}`, { + method: 'GET', + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Filters + case 'get-filters': { + const filterType = formData.get('type')?.toString() || '' + const password = formData.get('password')?.toString() || '' + + const result = await serverFetch(request, `log/filters?pid=${projectId}&type=${filterType}`, { + method: 'GET', + headers: password ? { 'x-password': password } : undefined, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-errors-filters': { + const filterType = formData.get('type')?.toString() || '' + const password = formData.get('password')?.toString() || '' + + const result = await serverFetch(request, `log/errors-filters?pid=${projectId}&type=${filterType}`, { + method: 'GET', + headers: password ? { 'x-password': password } : undefined, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + case 'get-version-filters': { + const dataType = formData.get('dataType')?.toString() || 'traffic' + const column = formData.get('column')?.toString() || 'br' + const password = formData.get('password')?.toString() || '' + + const result = await serverFetch( + request, + `log/filters/versions?pid=${projectId}&type=${dataType}&column=${column}`, + { + method: 'GET', + headers: password ? { 'x-password': password } : undefined, + }, + ) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + + // Error status update + case 'update-error-status': { + const eid = formData.get('eid')?.toString() + const eids = formData.get('eids') ? JSON.parse(formData.get('eids')!.toString()) : undefined + const status = formData.get('status')?.toString() || 'active' + + const result = await serverFetch(request, 'log/error-status', { + method: 'PATCH', + body: { pid: projectId, eid, eids, status }, + }) + + if (result.error) { + return data({ intent, error: result.error as string }, { status: 400 }) + } + + return data( + { intent, success: true, data: result.data }, + { headers: createHeadersWithCookies(result.cookies) }, + ) + } + default: return data({ error: 'Unknown action' }, { status: 400 }) } From 425248455a7a488e84ea64dac03ad13068a95bfd Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 7 Jan 2026 02:31:34 +0000 Subject: [PATCH 17/57] billing stuff --- web/app/api/index.ts | 54 ++------ web/app/components/pricing/BillingPricing.tsx | 122 +++++++++-------- .../components/pricing/MarketingPricing.tsx | 18 +-- web/app/pages/Billing/index.tsx | 101 ++++++-------- web/app/providers/CurrentProjectProvider.tsx | 124 ++++++++++-------- web/app/routes/_index.tsx | 27 ++-- web/app/routes/billing.tsx | 98 +++++++++++++- web/app/routes/projects.$id.tsx | 37 ++++++ 8 files changed, 335 insertions(+), 246 deletions(-) diff --git a/web/app/api/index.ts b/web/app/api/index.ts index fd10e762d..c65c08f51 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -6,7 +6,6 @@ import _map from 'lodash/map' import { DEFAULT_ALERTS_TAKE, API_URL } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' import { SSOProvider } from '~/lib/models/Auth' -import { Metainfo } from '~/lib/models/Metainfo' import { Project, Overall, @@ -1250,34 +1249,6 @@ export const getUserFlow = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const checkPassword = (pid: string, password: string) => - api - .get(`project/password/${pid}`, { - headers: { - 'x-password': password, - }, - }) - .then((response): boolean => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getPaymentMetainfo = (config?: AxiosRequestConfig) => - api - .get('user/metainfo', config) - .then((response): Metainfo => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getUsageInfo = () => - api - .get('user/usageinfo') - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getFilters = (pid: string, type: string, password = '') => api .get(`log/filters?pid=${pid}&type=${type}`, { @@ -1314,22 +1285,6 @@ export const getVersionFilters = (pid: string, type: 'traffic' | 'errors', colum throw error }) -export const previewSubscriptionUpdate = (planId: number) => - api - .post('user/preview-plan', { planId }) - .then((response) => response.data) - .catch((error) => { - throw error - }) - -export const changeSubscriptionPlan = (planId: number) => - api - .post('user/change-plan', { planId }) - .then((response) => response.data) - .catch((error) => { - throw error - }) - export const updateErrorStatus = (pid: string, status: 'resolved' | 'active', eid?: string, eids?: string[]) => api .patch('log/error-status', { pid, eid, eids, status }) @@ -1567,3 +1522,12 @@ export const getRevenueData = async ( throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message }) } + +// Blog Sitemap (kept client-side for SitemapFunction - no request object available) +export const getSitemap = () => + api + .get('v1/blog/sitemap') + .then((response): (string | string[])[] => response.data) + .catch((error) => { + throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message + }) diff --git a/web/app/components/pricing/BillingPricing.tsx b/web/app/components/pricing/BillingPricing.tsx index ae6d2a7b8..a02fbee76 100644 --- a/web/app/components/pricing/BillingPricing.tsx +++ b/web/app/components/pricing/BillingPricing.tsx @@ -7,9 +7,9 @@ import _map from 'lodash/map' import { CircleHelpIcon } from 'lucide-react' import React, { memo, useEffect, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { changeSubscriptionPlan, getPaymentMetainfo, previewSubscriptionUpdate } from '~/api' import { BillingFrequency, CURRENCIES, @@ -19,9 +19,10 @@ import { PURCHASABLE_LEGACY_PLANS, paddleLanguageMapping, } from '~/lib/constants' -import { DEFAULT_METAINFO, Metainfo } from '~/lib/models/Metainfo' +import { Metainfo, DEFAULT_METAINFO } from '~/lib/models/Metainfo' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' +import type { BillingActionData } from '~/routes/billing' import { Badge } from '~/ui/Badge' import Button from '~/ui/Button' import Loader from '~/ui/Loader' @@ -32,11 +33,12 @@ import routes from '~/utils/routes' interface BillingPricingProps { lastEvent?: { event: string } | null + metainfo?: Metainfo } const formatEventsLong = (value: number): string => value.toLocaleString('en-US') -const BillingPricing = ({ lastEvent }: BillingPricingProps) => { +const BillingPricing = ({ lastEvent, metainfo = DEFAULT_METAINFO }: BillingPricingProps) => { const { t, i18n: { language }, @@ -44,12 +46,12 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { const { isAuthenticated, user, loadUser } = useAuth() const { theme } = useTheme() - const [metainfo, setMetainfo] = useState(DEFAULT_METAINFO) + const previewFetcher = useFetcher() + const changePlanFetcher = useFetcher() + const [planCodeLoading, setPlanCodeLoading] = useState(null) const [isNewPlanConfirmationModalOpened, setIsNewPlanConfirmationModalOpened] = useState(false) - const [subUpdatePreview, setSubUpdatePreview] = useState(null) const [newPlanId, setNewPlanId] = useState(null) - const [isSubUpdating, setIsSubUpdating] = useState(false) const [downgradeTo, setDowngradeTo] = useState<{ planCode: string name: string @@ -60,15 +62,51 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { const [billingFrequency, setBillingFrequency] = useState(user?.billingFrequency || BillingFrequency.monthly) const [enableLegacyPlans, setEnableLegacyPlans] = useState(false) + const subUpdatePreview = useMemo(() => { + if (previewFetcher.state === 'loading' || previewFetcher.state === 'submitting') { + return null + } + if (previewFetcher.data?.success && previewFetcher.data.data) { + return previewFetcher.data.data + } + if (previewFetcher.data?.error) { + return false + } + return null + }, [previewFetcher.data, previewFetcher.state]) + + const isSubUpdating = changePlanFetcher.state === 'submitting' || changePlanFetcher.state === 'loading' + useEffect(() => { const urlParams = new URLSearchParams(window.location.search) const legacyParam = urlParams.get('__ENABLE_LEGACY_PLANS') if (legacyParam === 'true') { - // eslint-disable-next-line react-hooks/set-state-in-effect -- Reading URL param once on mount setEnableLegacyPlans(true) } }, []) + // Handle change plan fetcher response + useEffect(() => { + if (changePlanFetcher.data?.success) { + loadUser() + toast.success(t('apiNotifications.subscriptionUpdated')) + closeUpdateModal(true) + } else if (changePlanFetcher.data?.error) { + console.error('[ERROR] An error occured while updating subscription:', changePlanFetcher.data.error) + toast.error('An error occured while updating subscription') + closeUpdateModal(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [changePlanFetcher.data]) + + // Handle preview fetcher error + useEffect(() => { + if (previewFetcher.data?.error) { + console.error('[ERROR] An error occured while loading subscription update pricing preview:', previewFetcher.data.error) + toast.error('An error occured while loading subscription update pricing preview') + } + }, [previewFetcher.data?.error]) + const PLAN_CODES_ARRAY = useMemo(() => { let basePlans = STANDARD_PLANS @@ -80,7 +118,6 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { const userPlan = user?.planCode - // Hide non-purchasable pseudo-plans from the list if (userPlan === 'trial' || userPlan === 'none') { return basePlans } @@ -91,14 +128,6 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { const currencyCode = user?.tierCurrency || metainfo.code const currency = CURRENCIES[currencyCode] - useEffect(() => { - const abortController = new AbortController() - getPaymentMetainfo({ signal: abortController.signal }) - .then(setMetainfo) - .catch(() => {}) - return () => abortController.abort() - }, []) - useEffect(() => { if (!lastEvent) return @@ -119,16 +148,12 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { lastEventHandler(lastEvent) }, [lastEvent, t, loadUser]) - const loadSubUpdatePreview = async (planId: number) => { + const loadSubUpdatePreview = (planId: number) => { setIsNewPlanConfirmationModalOpened(true) - try { - const preview = await previewSubscriptionUpdate(planId) - setSubUpdatePreview(preview) - } catch (reason) { - console.error('[ERROR] An error occured while loading subscription update pricing preview:', reason) - toast.error('An error occured while loading subscription update pricing preview') - setSubUpdatePreview(false) - } + previewFetcher.submit( + { intent: 'preview-subscription-update', planId: String(planId) }, + { method: 'POST', action: '/billing' }, + ) } const onPlanChange = async (tier: any) => { @@ -144,7 +169,7 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { if (user.subID && !user.cancellationEffectiveDate && user.planCode !== 'none') { const planId = Number(billingFrequency === BillingFrequency.monthly ? tier.pid : tier.ypid) setNewPlanId(planId) - await loadSubUpdatePreview(planId) + loadSubUpdatePreview(planId) return } @@ -170,23 +195,14 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { const closeUpdateModal = (force?: boolean) => { if (isSubUpdating && !force) return setIsNewPlanConfirmationModalOpened(false) - setSubUpdatePreview(null) setNewPlanId(null) - setIsSubUpdating(false) } - const updateSubscription = async () => { - setIsSubUpdating(true) - try { - await changeSubscriptionPlan(newPlanId as number) - await loadUser() - toast.success(t('apiNotifications.subscriptionUpdated')) - closeUpdateModal(true) - } catch (reason) { - console.error('[ERROR] An error occured while updating subscription:', reason) - toast.error('An error occured while updating subscription') - closeUpdateModal(true) - } + const updateSubscription = () => { + changePlanFetcher.submit( + { intent: 'change-subscription-plan', planId: String(newPlanId) }, + { method: 'POST', action: '/billing' }, + ) } const downgradeHandler = (tier: any) => { @@ -415,7 +431,7 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { />

) : null} - {subUpdatePreview ? ( + {subUpdatePreview && typeof subUpdatePreview === 'object' ? (

{t('billing.dueNow')}

{t('billing.dueNowDescription')}

@@ -440,27 +456,27 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { - {`${subUpdatePreview.immediatePayment.symbol}${subUpdatePreview.immediatePayment.amount}`} + {`${(subUpdatePreview as any).immediatePayment.symbol}${(subUpdatePreview as any).immediatePayment.amount}`} {language === 'en' - ? dayjs(subUpdatePreview.immediatePayment.date).locale(language).format('MMMM D, YYYY') - : dayjs(subUpdatePreview.immediatePayment.date).locale(language).format('D MMMM, YYYY')} + ? dayjs((subUpdatePreview as any).immediatePayment.date).locale(language).format('MMMM D, YYYY') + : dayjs((subUpdatePreview as any).immediatePayment.date).locale(language).format('D MMMM, YYYY')}
- {subUpdatePreview.immediatePayment.amount < 0 ? ( + {(subUpdatePreview as any).immediatePayment.amount < 0 ? (

{t('billing.negativePayment', { - currency: subUpdatePreview.immediatePayment.symbol, - dueNowAmount: -subUpdatePreview.immediatePayment.amount, + currency: (subUpdatePreview as any).immediatePayment.symbol, + dueNowAmount: -(subUpdatePreview as any).immediatePayment.amount, dueNowDate: language === 'en' - ? dayjs(subUpdatePreview.immediatePayment.date).locale(language).format('MMMM D, YYYY') - : dayjs(subUpdatePreview.immediatePayment.date).locale(language).format('D MMMM, YYYY'), - nextPaymentAmount: subUpdatePreview.nextPayment.amount, + ? dayjs((subUpdatePreview as any).immediatePayment.date).locale(language).format('MMMM D, YYYY') + : dayjs((subUpdatePreview as any).immediatePayment.date).locale(language).format('D MMMM, YYYY'), + nextPaymentAmount: (subUpdatePreview as any).nextPayment.amount, })}

) : null} @@ -486,12 +502,12 @@ const BillingPricing = ({ lastEvent }: BillingPricingProps) => { - {`${subUpdatePreview.nextPayment.symbol}${subUpdatePreview.nextPayment.amount}`} + {`${(subUpdatePreview as any).nextPayment.symbol}${(subUpdatePreview as any).nextPayment.amount}`} {language === 'en' - ? dayjs(subUpdatePreview.nextPayment.date).locale(language).format('MMMM D, YYYY') - : dayjs(subUpdatePreview.nextPayment.date).locale(language).format('D MMMM, YYYY')} + ? dayjs((subUpdatePreview as any).nextPayment.date).locale(language).format('MMMM D, YYYY') + : dayjs((subUpdatePreview as any).nextPayment.date).locale(language).format('D MMMM, YYYY')} diff --git a/web/app/components/pricing/MarketingPricing.tsx b/web/app/components/pricing/MarketingPricing.tsx index 1e4427038..e8dc44b0e 100644 --- a/web/app/components/pricing/MarketingPricing.tsx +++ b/web/app/components/pricing/MarketingPricing.tsx @@ -3,11 +3,10 @@ import { CheckIcon } from '@heroicons/react/24/solid' import cx from 'clsx' import _map from 'lodash/map' import { ArrowRightIcon, CircleHelpIcon } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router' -import { getPaymentMetainfo } from '~/api' import { CURRENCIES, PLAN_LIMITS, STANDARD_PLANS, BillingFrequency, TRIAL_DAYS } from '~/lib/constants' import { DEFAULT_METAINFO, Metainfo } from '~/lib/models/Metainfo' import { useAuth } from '~/providers/AuthProvider' @@ -16,20 +15,15 @@ import routes from '~/utils/routes' const formatEventsLong = (value: number) => value.toLocaleString('en-US') -const MarketingPricing = () => { +interface MarketingPricingProps { + metainfo?: Metainfo +} + +const MarketingPricing = ({ metainfo = DEFAULT_METAINFO }: MarketingPricingProps) => { const { isAuthenticated } = useAuth() const { t } = useTranslation('common') - const [metainfo, setMetainfo] = useState(DEFAULT_METAINFO) const [billingFrequency, setBillingFrequency] = useState(BillingFrequency.monthly) - useEffect(() => { - const abortController = new AbortController() - getPaymentMetainfo({ signal: abortController.signal }) - .then(setMetainfo) - .catch(() => {}) - return () => abortController.abort() - }, []) - const currencyCode = metainfo.code const currency = CURRENCIES[currencyCode] diff --git a/web/app/pages/Billing/index.tsx b/web/app/pages/Billing/index.tsx index 42a7a2a0c..5a976344f 100644 --- a/web/app/pages/Billing/index.tsx +++ b/web/app/pages/Billing/index.tsx @@ -5,18 +5,19 @@ import utc from 'dayjs/plugin/utc' import _round from 'lodash/round' import { memo, useMemo, useState, useEffect } from 'react' import { useTranslation, Trans } from 'react-i18next' +import { useLoaderData, useFetcher } from 'react-router' import { toast } from 'sonner' -import { getPaymentMetainfo, getUsageInfo } from '~/api' import DashboardLockedBanner from '~/components/DashboardLockedBanner' import FAQ from '~/components/marketing/FAQ' import BillingPricing from '~/components/pricing/BillingPricing' import { withAuthentication, auth } from '~/hoc/protected' -import { isSelfhosted, PADDLE_JS_URL, PADDLE_VENDOR_ID, CONTACT_EMAIL, paddleLanguageMapping } from '~/lib/constants' -import { DEFAULT_METAINFO, Metainfo } from '~/lib/models/Metainfo' +import { PADDLE_JS_URL, PADDLE_VENDOR_ID, CONTACT_EMAIL, paddleLanguageMapping } from '~/lib/constants' +import { DEFAULT_METAINFO } from '~/lib/models/Metainfo' import { UsageInfo } from '~/lib/models/Usageinfo' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' +import type { BillingActionData, BillingLoaderData } from '~/routes/billing' import Button from '~/ui/Button' import Loader from '~/ui/Loader' import Modal from '~/ui/Modal' @@ -26,31 +27,43 @@ import { loadScript } from '~/utils/generic' dayjs.extend(utc) dayjs.extend(duration) +const DEFAULT_USAGE_INFO: UsageInfo = { + total: 0, + traffic: 0, + errors: 0, + customEvents: 0, + captcha: 0, + trafficPerc: 0, + errorsPerc: 0, + customEventsPerc: 0, + captchaPerc: 0, +} + const Billing = () => { - const [isCancelSubModalOpened, setIsCancelSubModalOpened] = useState(false) + const loaderData = useLoaderData() + const metainfoFetcher = useFetcher() - const [metainfo, setMetainfo] = useState(DEFAULT_METAINFO) - const [lastEvent, setLastEvent] = useState<{ - event: string - } | null>(null) + const [isCancelSubModalOpened, setIsCancelSubModalOpened] = useState(false) + const [lastEvent, setLastEvent] = useState<{ event: string } | null>(null) const { user, isLoading: authLoading } = useAuth() const { theme } = useTheme() - const [usageInfo, setUsageInfo] = useState({ - total: 0, - traffic: 0, - errors: 0, - customEvents: 0, - captcha: 0, - trafficPerc: 0, - errorsPerc: 0, - customEventsPerc: 0, - captchaPerc: 0, - }) + + const metainfo = useMemo(() => { + if (metainfoFetcher.data?.success && metainfoFetcher.data.data) { + return metainfoFetcher.data.data as typeof DEFAULT_METAINFO + } + return loaderData?.metainfo ?? DEFAULT_METAINFO + }, [loaderData?.metainfo, metainfoFetcher.data]) + + const usageInfo = useMemo(() => loaderData?.usageInfo ?? DEFAULT_USAGE_INFO, [loaderData?.usageInfo]) + const isLoading = !loaderData + const { t, i18n: { language }, } = useTranslation('common') + const { nextBillDate, planCode, @@ -69,30 +82,15 @@ const Billing = () => { const totalUsage = maxEventsCount ? _round((usageInfo.total / maxEventsCount) * 100, 2) : 0 const remainingUsage = _round(100 - totalUsage, 2) - const [isLoading, setIsLoading] = useState(true) - - useEffect(() => { - const abortController = new AbortController() - - getPaymentMetainfo({ signal: abortController.signal }) - .then(setMetainfo) - .catch(() => {}) - - return () => abortController.abort() - }, []) - // Paddle (payment processor) set-up useEffect(() => { loadScript(PADDLE_JS_URL) const interval = setInterval(paddleSetup, 200) - // prettier-ignore function paddleSetup() { - if (isSelfhosted) { - clearInterval(interval) - } else if ((window as any)?.Paddle) { - (window as any).Paddle.Setup({ + if ((window as any)?.Paddle) { + ;(window as any).Paddle.Setup({ vendor: PADDLE_VENDOR_ID, eventCallback: setLastEvent, }) @@ -101,31 +99,6 @@ const Billing = () => { } }, []) - const loadUsageInfo = async () => { - if (!isLoading) { - return - } - - try { - const result = await getUsageInfo() - setUsageInfo(result) - } catch (reason: any) { - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.failedToLoadUsageInfo')) - } finally { - setIsLoading(false) - } - } - - useEffect(() => { - if (authLoading) { - return - } - - loadUsageInfo() - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [authLoading]) - const isTrialEnded = useMemo(() => { if (!trialEndDate) { return false @@ -307,11 +280,11 @@ const Billing = () => {
) : null} - {isLoading ? ( + {isLoading || authLoading ? ( ) : (
- +
{subUpdateURL && !cancellationEffectiveDate ? (
- +
{ {() => }
- +
diff --git a/web/app/routes/open.tsx b/web/app/routes/open.tsx index 587fee438..84fd91d28 100644 --- a/web/app/routes/open.tsx +++ b/web/app/routes/open.tsx @@ -1,7 +1,7 @@ -import { redirect } from 'react-router' -import type { LinksFunction } from 'react-router' +import { redirect, type LoaderFunctionArgs, useLoaderData, LinksFunction } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' +import { getGeneralStats } from '~/api/api.server' import { isDisableMarketingPages, isSelfhosted } from '~/lib/constants' import OpenStartup from '~/pages/OpenStartup' import Style from '~/styles/ProjectViewStyle.css?url' @@ -15,14 +15,18 @@ export const sitemap: SitemapFunction = () => ({ exclude: isSelfhosted || isDisableMarketingPages, }) -export async function loader() { +export async function loader({ request }: LoaderFunctionArgs) { if (isSelfhosted || isDisableMarketingPages) { return redirect('/login', 302) } - return null + const stats = await getGeneralStats(request) + + return { stats } } export default function Index() { - return + const { stats } = useLoaderData() + + return } From 6d1f6c3f86c1051bb098b679839494d1cc58fdc2 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 7 Jan 2026 23:57:23 +0000 Subject: [PATCH 19/57] move project info retreival to loader --- .../Project/Settings/ProjectSettings.tsx | 169 +++++++++-------- web/app/providers/CurrentProjectProvider.tsx | 171 +++++++----------- web/app/routes/projects.$id.tsx | 58 +++++- web/app/routes/projects.settings.$id.tsx | 92 ++++++++++ web/app/utils/session.server.ts | 19 ++ 5 files changed, 320 insertions(+), 189 deletions(-) diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index 7cc78afb9..6eeb9db65 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -22,20 +22,12 @@ import { ShieldCheckIcon, DollarSignIcon, } from 'lucide-react' -import React, { useState, useEffect, useMemo, useCallback } from 'react' +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useLoaderData, useNavigate, Link, useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' -import { - getFilters, - getProject, - generateGSCAuthURL, - getGSCStatus, - getGSCProperties, - setGSCProperty, - disconnectGSC, -} from '~/api' +import { getFilters, getProject } from '~/api' import { useRequiredParams } from '~/hooks/useRequiredParams' import { isSelfhosted, TITLE_SUFFIX, FILTERS_PANELS_ORDER, isBrowser } from '~/lib/constants' import { Project } from '~/lib/models/Project' @@ -276,6 +268,7 @@ const ProjectSettings = () => { const navigate = useNavigate() const { requestOrigin } = useLoaderData<{ requestOrigin: string | null }>() const fetcher = useFetcher() + const gscFetcher = useFetcher() const [project, setProject] = useState(null) const [form, setForm] = useState
({ @@ -453,33 +446,85 @@ const ProjectSettings = () => { } } - const refreshGSCStatus = useCallback(async () => { - if (isSelfhosted) { - return - } + const [gscPropertiesPending, setGscPropertiesPending] = useState(false) + const lastHandledGscData = useRef(null) + const gscInitialized = useRef(false) - try { - const { connected, email } = await getGSCStatus(id) - setGscConnected(connected) - setGscEmail(email || null) - if (connected) { - try { - const props = await getGSCProperties(id) - setGscProperties(props) - } catch { - // + // Handle GSC fetcher responses + useEffect(() => { + if (gscFetcher.state !== 'idle' || !gscFetcher.data) return + + // Prevent handling the same response twice + if (lastHandledGscData.current === gscFetcher.data) return + lastHandledGscData.current = gscFetcher.data + + const { intent, success, gscStatus, gscProperties: properties, gscAuthUrl, error: gscError } = gscFetcher.data + + if (success) { + if (intent === 'gsc-status' && gscStatus) { + setGscConnected(gscStatus.connected) + setGscEmail(gscStatus.email || null) + if (gscStatus.connected) { + setGscPropertiesPending(true) + } else { + setGscProperties([]) } - } else { + } else if (intent === 'gsc-properties' && properties) { + setGscProperties(properties) + setGscPropertiesPending(false) + } else if (intent === 'gsc-connect' && gscAuthUrl) { + const safeUrl = (() => { + try { + const parsed = new URL(gscAuthUrl) + if (parsed.protocol !== 'https:') return null + if (parsed.username || parsed.password) return null + if (parsed.hostname !== 'accounts.google.com') return null + return parsed.toString() + } catch { + return null + } + })() + + if (!safeUrl) { + toast.error(t('apiNotifications.somethingWentWrong')) + return + } + + window.location.href = safeUrl + } else if (intent === 'gsc-disconnect') { + setGscConnected(false) setGscProperties([]) + setGscEmail(null) + setForm((prevForm) => ({ + ...prevForm, + gscPropertyUri: null, + })) + toast.success(t('project.settings.gsc.disconnected')) + } else if (intent === 'gsc-set-property') { + toast.success(t('project.settings.gsc.propertyConnected')) } - } catch { - setGscConnected(false) + } else if (gscError) { + toast.error(typeof gscError === 'string' ? gscError : t('apiNotifications.somethingWentWrong')) + setGscPropertiesPending(false) } - }, [id]) + }, [gscFetcher.state, gscFetcher.data, t]) + // Fetch GSC properties after status confirms connected useEffect(() => { - refreshGSCStatus() - }, [refreshGSCStatus]) + if (gscPropertiesPending && gscFetcher.state === 'idle') { + setGscPropertiesPending(false) + gscFetcher.submit({ intent: 'gsc-properties' }, { method: 'post' }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gscPropertiesPending, gscFetcher.state]) + + // Initial GSC status fetch + useEffect(() => { + if (isSelfhosted || gscInitialized.current) return + gscInitialized.current = true + gscFetcher.submit({ intent: 'gsc-status' }, { method: 'post' }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) useEffect(() => { if (authLoading) { @@ -891,32 +936,12 @@ const ProjectSettings = () => { @@ -981,15 +997,16 @@ const ProjectSettings = () => {
)} - + setShowModal(false)} onSubmit={onDeleteAlert} diff --git a/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx b/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx index b073c6637..d052b36e4 100644 --- a/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx +++ b/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx @@ -17,12 +17,12 @@ import { FileTextIcon, BellOffIcon, } from 'lucide-react' -import { useMemo, useState, useEffect } from 'react' +import { useMemo, useState, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useSearchParams } from 'react-router' +import { Link, useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' -import { deleteAlert as deleteAlertApi, getProjectAlerts } from '~/api' +import type { ProjectViewActionData } from '~/routes/projects.$id' import { QUERY_METRIC, PLAN_LIMITS, DEFAULT_ALERTS_TAKE } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' import PaidFeature from '~/modals/PaidFeature' @@ -217,6 +217,8 @@ const ProjectAlerts = () => { const { user, isAuthenticated } = useAuth() const [isPaidFeatureOpened, setIsPaidFeatureOpened] = useState(false) const [searchParams, setSearchParams] = useSearchParams() + const fetcher = useFetcher() + const lastHandledData = useRef(null) const [isLoading, setIsLoading] = useState(null) const [total, setTotal] = useState(0) @@ -245,22 +247,40 @@ const ProjectAlerts = () => { return newSearchParams.toString() }, [searchParams]) - const loadAlerts = async (take: number, skip: number) => { - if (isLoading) { - return - } + const loadAlerts = (take: number, skip: number) => { + if (fetcher.state !== 'idle') return setIsLoading(true) - try { - const result = await getProjectAlerts(id, take, skip) - setAlerts(result.results) - setTotal(result.total) - } catch (reason: any) { - setError(reason) - } finally { + fetcher.submit( + { intent: 'get-project-alerts', take: String(take), skip: String(skip) }, + { method: 'POST', action: `/projects/${id}` }, + ) + } + + // Handle fetcher responses + useEffect(() => { + if (fetcher.state !== 'idle' || !fetcher.data) return + if (lastHandledData.current === fetcher.data) return + lastHandledData.current = fetcher.data + + const { intent, success, data, error: fetcherError } = fetcher.data + + if (success) { + if (intent === 'get-project-alerts' && data) { + const result = data as { results: Alerts[]; total: number } + setAlerts(result.results) + setTotal(result.total) + setIsLoading(false) + } else if (intent === 'delete-alert') { + toast.success(t('alertsSettings.alertDeleted')) + loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) + } + } else if (fetcherError) { + setError(fetcherError) setIsLoading(false) } - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.state, fetcher.data, t, page]) useEffect(() => { if (!canManageAlerts) { @@ -325,15 +345,10 @@ const ProjectAlerts = () => { loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) } - const onDelete = async (alertId: string) => { - try { - await deleteAlertApi(alertId) - toast.success(t('alertsSettings.alertDeleted')) - // Reload alerts after deletion - loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) - } catch (reason: any) { - toast.error(reason?.response?.data?.message || reason?.message || 'Something went wrong') - } + const onDelete = (alertId: string) => { + if (fetcher.state !== 'idle') return + + fetcher.submit({ intent: 'delete-alert', alertId }, { method: 'POST', action: `/projects/${id}` }) } if (!canManageAlerts) { diff --git a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx index 3cd5f3866..8556ec75c 100644 --- a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx +++ b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx @@ -18,11 +18,12 @@ import { } from 'lucide-react' import { useState, useEffect, useMemo, useRef, useCallback, lazy, Suspense } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useLocation, useSearchParams, useNavigate, type LinkProps } from 'react-router' +import { Link, useLocation, useSearchParams, useNavigate, useFetcher, type LinkProps } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { getErrors, getErrorOverview, getError, updateErrorStatus, type ErrorOverviewResponse } from '~/api' +import { getErrors, getErrorOverview, getError, type ErrorOverviewResponse } from '~/api' +import type { ProjectViewActionData } from '~/routes/projects.$id' import { TimeFormat, tbsFormatMapper, @@ -399,6 +400,9 @@ const ErrorsView = () => { const [searchParams] = useSearchParams() const isEmbedded = searchParams.get('embedded') === 'true' const navigate = useNavigate() + const errorStatusFetcher = useFetcher() + const lastHandledStatusData = useRef(null) + const pendingStatusUpdate = useRef<'resolved' | 'active' | null>(null) const from = dateRange ? getFormatDate(dateRange[0]) : '' const to = dateRange ? getFormatDate(dateRange[1]) : '' @@ -423,7 +427,8 @@ const ErrorsView = () => { const prevActiveEIDRef = useRef(activeEID) const [activeError, setActiveError] = useState<{ details: SwetrixErrorDetails; [key: string]: any } | null>(null) const [errorLoading, setErrorLoading] = useState(false) - const [errorStatusUpdating, setErrorStatusUpdating] = useState(false) + + const errorStatusUpdating = errorStatusFetcher.state !== 'idle' const [errorsActiveTabs, setErrorsActiveTabs] = useState<{ location: 'cc' | 'rg' | 'ct' | 'lc' | 'map' @@ -572,44 +577,48 @@ const ErrorsView = () => { [activeError?.details?.eid], ) - const markErrorAsResolved = async () => { - if (errorStatusUpdating || !activeEID || !activeError?.details?.eid) return + // Handle error status update response + useEffect(() => { + if (errorStatusFetcher.state !== 'idle' || !errorStatusFetcher.data) return + if (lastHandledStatusData.current === errorStatusFetcher.data) return + lastHandledStatusData.current = errorStatusFetcher.data - setErrorStatusUpdating(true) + const { intent, success, error } = errorStatusFetcher.data - try { - await updateErrorStatus(id, 'resolved', activeEID) - await loadError(activeEID) - updateStatusInErrors('resolved') - } catch (reason) { - console.error('[markErrorAsResolved]', reason) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.updateErrorStatusFailed')) - setErrorStatusUpdating(false) - return + if (intent === 'update-error-status') { + if (success && pendingStatusUpdate.current) { + updateStatusInErrors(pendingStatusUpdate.current) + if (activeEID) { + loadError(activeEID) + } + toast.success(t('apiNotifications.errorStatusUpdated')) + pendingStatusUpdate.current = null + } else if (error) { + toast.error(error) + pendingStatusUpdate.current = null + } } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [errorStatusFetcher.state, errorStatusFetcher.data, t, activeEID]) - toast.success(t('apiNotifications.errorStatusUpdated')) - setErrorStatusUpdating(false) - } - - const markErrorAsActive = async () => { + const markErrorAsResolved = () => { if (errorStatusUpdating || !activeEID || !activeError?.details?.eid) return - setErrorStatusUpdating(true) + pendingStatusUpdate.current = 'resolved' + errorStatusFetcher.submit( + { intent: 'update-error-status', eid: activeEID, status: 'resolved' }, + { method: 'POST', action: `/projects/${id}` }, + ) + } - try { - await updateErrorStatus(id, 'active', activeEID) - await loadError(activeEID) - updateStatusInErrors('active') - } catch (reason) { - console.error('[markErrorAsActive]', reason) - toast.error(typeof reason === 'string' ? reason : t('apiNotifications.updateErrorStatusFailed')) - setErrorStatusUpdating(false) - return - } + const markErrorAsActive = () => { + if (errorStatusUpdating || !activeEID || !activeError?.details?.eid) return - toast.success(t('apiNotifications.errorStatusUpdated')) - setErrorStatusUpdating(false) + pendingStatusUpdate.current = 'active' + errorStatusFetcher.submit( + { intent: 'update-error-status', eid: activeEID, status: 'active' }, + { method: 'POST', action: `/projects/${id}` }, + ) } const switchActiveErrorFilter = useMemo( diff --git a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx index 40dbd0f46..599e6380d 100644 --- a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx +++ b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx @@ -6,10 +6,11 @@ import _isEmpty from 'lodash/isEmpty' import { FilterIcon } from 'lucide-react' import { useState, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useSearchParams } from 'react-router' +import { Link, useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' -import { addFunnel, updateFunnel, deleteFunnel, getFunnelData, getFunnels } from '~/api' +import { getFunnelData } from '~/api' +import type { ProjectViewActionData } from '~/routes/projects.$id' import { FUNNELS_PERIOD_PAIRS } from '~/lib/constants' import { Funnel, AnalyticsFunnel } from '~/lib/models/Project' import NewFunnel from '~/modals/NewFunnel' @@ -43,11 +44,12 @@ const FunnelsView = () => { const isEmbedded = searchParams.get('embedded') === 'true' const isMountedRef = useRef(true) + const fetcher = useFetcher() + const lastHandledData = useRef(null) // Funnels state const [isNewFunnelOpened, setIsNewFunnelOpened] = useState(false) const [funnelToEdit, setFunnelToEdit] = useState(undefined) - const [funnelActionLoading, setFunnelActionLoading] = useState(false) const [dataLoading, setDataLoading] = useState(false) const [analyticsLoading, setAnalyticsLoading] = useState(true) const [funnelAnalytics, setFunnelAnalytics] = useState<{ @@ -55,6 +57,8 @@ const FunnelsView = () => { totalPageviews: number } | null>(null) + const funnelActionLoading = fetcher.state !== 'idle' + // Search params without the funnel id. Needed for the back button. const pureSearchParams = useMemo(() => { const newSearchParams = new URLSearchParams(searchParams.toString()) @@ -85,79 +89,68 @@ const FunnelsView = () => { } }, []) - const onFunnelCreate = async (name: string, steps: string[]) => { - if (funnelActionLoading) { - return + // Handle fetcher responses + useEffect(() => { + if (fetcher.state !== 'idle' || !fetcher.data) return + if (lastHandledData.current === fetcher.data) return + lastHandledData.current = fetcher.data + + const { intent, success, error } = fetcher.data + + if (success) { + if (intent === 'create-funnel') { + toast.success(t('apiNotifications.funnelCreated')) + setIsNewFunnelOpened(false) + setFunnelToEdit(undefined) + // Reload funnels list + fetcher.submit( + { intent: 'get-funnels', password: projectPassword }, + { method: 'POST', action: `/projects/${id}` }, + ) + } else if (intent === 'update-funnel') { + toast.success(t('apiNotifications.funnelUpdated')) + setIsNewFunnelOpened(false) + setFunnelToEdit(undefined) + fetcher.submit( + { intent: 'get-funnels', password: projectPassword }, + { method: 'POST', action: `/projects/${id}` }, + ) + } else if (intent === 'delete-funnel') { + toast.success(t('apiNotifications.funnelDeleted')) + fetcher.submit( + { intent: 'get-funnels', password: projectPassword }, + { method: 'POST', action: `/projects/${id}` }, + ) + } else if (intent === 'get-funnels' && fetcher.data.data) { + mergeProject({ funnels: fetcher.data.data as Funnel[] }) + } + } else if (error) { + toast.error(error) } + }, [fetcher.state, fetcher.data, t, id, projectPassword, fetcher, mergeProject]) - setFunnelActionLoading(true) + const onFunnelCreate = (name: string, steps: string[]) => { + if (funnelActionLoading) return - try { - await addFunnel(id, name, steps) - } catch (reason: any) { - console.error('[ERROR] (onFunnelCreate)(addFunnel)', reason) - toast.error(reason) - } - - try { - const funnels = await getFunnels(id, projectPassword) - mergeProject({ funnels }) - } catch (reason: any) { - console.error('[ERROR] (onFunnelCreate)(getFunnels)', reason) - } - - toast.success(t('apiNotifications.funnelCreated')) - setFunnelActionLoading(false) + fetcher.submit( + { intent: 'create-funnel', name, steps: JSON.stringify(steps) }, + { method: 'POST', action: `/projects/${id}` }, + ) } - const onFunnelEdit = async (funnelId: string, name: string, steps: string[]) => { - if (funnelActionLoading) { - return - } - - setFunnelActionLoading(true) - - try { - await updateFunnel(funnelId, id, name, steps) - } catch (reason: any) { - console.error('[ERROR] (onFunnelEdit)(updateFunnel)', reason) - toast.error(reason) - } - - try { - const funnels = await getFunnels(id, projectPassword) - mergeProject({ funnels }) - } catch (reason: any) { - console.error('[ERROR] (onFunnelEdit)(getFunnels)', reason) - } + const onFunnelEdit = (funnelId: string, name: string, steps: string[]) => { + if (funnelActionLoading) return - toast.success(t('apiNotifications.funnelUpdated')) - setFunnelActionLoading(false) + fetcher.submit( + { intent: 'update-funnel', funnelId, name, steps: JSON.stringify(steps) }, + { method: 'POST', action: `/projects/${id}` }, + ) } - const onFunnelDelete = async (funnelId: string) => { - if (funnelActionLoading) { - return - } - - setFunnelActionLoading(true) - - try { - await deleteFunnel(funnelId, id) - } catch (reason: any) { - console.error('[ERROR] (onFunnelDelete)(deleteFunnel)', reason) - toast.error(reason) - } - - try { - const funnels = await getFunnels(id, projectPassword) - mergeProject({ funnels }) - } catch (reason: any) { - console.error('[ERROR] (onFunnelDelete)(getFunnels)', reason) - } + const onFunnelDelete = (funnelId: string) => { + if (funnelActionLoading) return - toast.success(t('apiNotifications.funnelDeleted')) - setFunnelActionLoading(false) + fetcher.submit({ intent: 'delete-funnel', funnelId }, { method: 'POST', action: `/projects/${id}` }) } const loadFunnelsData = async () => { diff --git a/web/app/providers/CurrentProjectProvider.tsx b/web/app/providers/CurrentProjectProvider.tsx index a42972eb8..88050b3a2 100644 --- a/web/app/providers/CurrentProjectProvider.tsx +++ b/web/app/providers/CurrentProjectProvider.tsx @@ -58,14 +58,12 @@ const useProject = (id: string) => { const lastHandledFetcherData = useRef(null) const hasTriedStoredPassword = useRef(false) - // Handle initial loader error (only on mount) useEffect(() => { if (loaderData?.error && !loaderData.project && !loaderData.isPasswordRequired) { console.error('[ERROR] (getProject)', loaderData.error) toast.error(t('project.noExist')) navigate(routes.dashboard) } - // Only run on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index 092b4928d..dcc505317 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -217,13 +217,27 @@ export async function action({ request, params }: ActionFunctionArgs) { case 'create-alert': { const name = formData.get('name')?.toString() || '' const queryMetric = formData.get('queryMetric')?.toString() || 'page_views' - const queryCondition = formData.get('queryCondition')?.toString() || 'greater_than' - const queryValue = Number(formData.get('queryValue')?.toString() || '0') - const queryTime = Number(formData.get('queryTime')?.toString() || '1') + const queryCondition = formData.get('queryCondition')?.toString() || null + const queryValue = formData.get('queryValue') ? Number(formData.get('queryValue')) : null + const queryTime = formData.get('queryTime')?.toString() || null + const queryCustomEvent = formData.get('queryCustomEvent')?.toString() || null + const alertOnNewErrorsOnly = formData.get('alertOnNewErrorsOnly') === 'true' + const alertOnEveryCustomEvent = formData.get('alertOnEveryCustomEvent') === 'true' const result = await serverFetch(request, 'alert', { method: 'POST', - body: { pid: projectId, name, queryMetric, queryCondition, queryValue, queryTime, active: true }, + body: { + pid: projectId, + name, + queryMetric, + queryCondition, + queryValue, + queryTime, + queryCustomEvent, + alertOnNewErrorsOnly, + alertOnEveryCustomEvent, + active: true, + }, }) if (result.error) { @@ -241,17 +255,27 @@ export async function action({ request, params }: ActionFunctionArgs) { const name = formData.get('name')?.toString() const active = formData.get('active') === 'true' const queryMetric = formData.get('queryMetric')?.toString() - const queryCondition = formData.get('queryCondition')?.toString() - const queryValue = formData.get('queryValue') ? Number(formData.get('queryValue')) : undefined - const queryTime = formData.get('queryTime') ? Number(formData.get('queryTime')) : undefined + const queryCondition = formData.get('queryCondition')?.toString() || null + const queryValue = formData.get('queryValue') ? Number(formData.get('queryValue')) : null + const queryTime = formData.get('queryTime')?.toString() || null + const queryCustomEvent = formData.get('queryCustomEvent')?.toString() || null + const alertOnNewErrorsOnly = formData.has('alertOnNewErrorsOnly') + ? formData.get('alertOnNewErrorsOnly') === 'true' + : undefined + const alertOnEveryCustomEvent = formData.has('alertOnEveryCustomEvent') + ? formData.get('alertOnEveryCustomEvent') === 'true' + : undefined const body: Record = {} if (name !== undefined) body.name = name body.active = active if (queryMetric !== undefined) body.queryMetric = queryMetric - if (queryCondition !== undefined) body.queryCondition = queryCondition - if (queryValue !== undefined) body.queryValue = queryValue - if (queryTime !== undefined) body.queryTime = queryTime + body.queryCondition = queryCondition + body.queryValue = queryValue + body.queryTime = queryTime + body.queryCustomEvent = queryCustomEvent + if (alertOnNewErrorsOnly !== undefined) body.alertOnNewErrorsOnly = alertOnNewErrorsOnly + if (alertOnEveryCustomEvent !== undefined) body.alertOnEveryCustomEvent = alertOnEveryCustomEvent const result = await serverFetch(request, `alert/${alertId}`, { method: 'PUT', diff --git a/web/app/utils/session.server.ts b/web/app/utils/session.server.ts index 227da96fa..f489aff32 100644 --- a/web/app/utils/session.server.ts +++ b/web/app/utils/session.server.ts @@ -172,7 +172,3 @@ export function createProjectPasswordCookie(projectId: string, password: string) sameSite: 'Lax', }) } - -export function clearProjectPasswordCookie(projectId: string): string { - return `${PROJECT_PASSWORD_COOKIE_PREFIX}${projectId}=; Path=/; Max-Age=0; SameSite=Lax` -} From 2b58d8d06207b737e86cb6c0116f21ad365cb695 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Fri, 9 Jan 2026 20:58:17 +0000 Subject: [PATCH 21/57] move traffic & performance to SSR --- web/app/api/api.server.ts | 314 ++++++++++ web/app/hooks/useAnalyticsProxy.ts | 76 +++ .../pages/Project/tabs/Errors/ErrorsView.tsx | 86 ++- .../tabs/Performance/PerformanceView.tsx | 409 ++++---------- .../Project/tabs/Sessions/SessionsView.tsx | 83 ++- .../Project/tabs/Traffic/TrafficView.tsx | 534 +++++++----------- web/app/routes/api.analytics.ts | 85 +++ web/app/routes/projects.$id.tsx | 217 ++++++- 8 files changed, 1134 insertions(+), 670 deletions(-) create mode 100644 web/app/hooks/useAnalyticsProxy.ts create mode 100644 web/app/routes/api.analytics.ts diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index df16d2730..5366e3a62 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -471,3 +471,317 @@ export async function getGeneralStats(request: Request): Promise + chart: { + x: string[] + visits: number[] + uniques: number[] + sdur: number[] + } + customs: Record + properties: Record + appliedFilters?: AnalyticsFilter[] + timeBucket?: string[] + meta?: { key: string; current: { sum: number; avg: number }; previous: { sum: number; avg: number } }[] +} + +interface OverallPeriodStats { + all: number + unique?: number + users?: number + bounceRate?: number + sdur?: number +} + +export interface OverallObject { + current: OverallPeriodStats + previous: OverallPeriodStats + change: number + uniqueChange?: number + usersChange?: number + bounceRateChange?: number + sdurChange?: number + customEVFilterApplied?: boolean + chart?: { x: string[]; visits: number[] } +} + +export interface AnalyticsParams { + timeBucket: string + period: string + filters: AnalyticsFilter[] + from?: string + to?: string + timezone: string + mode?: 'periodical' | 'cumulative' + password?: string +} + +function serializeFiltersForUrl(filters: AnalyticsFilter[]): string { + return JSON.stringify(filters) +} + +export async function getProjectDataServer( + request: Request, + pid: string, + params: AnalyticsParams, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + timeBucket: params.timeBucket, + period: params.period, + filters: serializeFiltersForUrl(params.filters), + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + mode: params.mode || 'periodical', + metrics: '[]', + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log?${queryParams.toString()}`, { headers }) +} + +export async function getOverallStatsServer( + request: Request, + pids: string[], + params: AnalyticsParams, +): Promise>> { + const pidsParam = `[${pids.map((pid) => `"${pid}"`).join(',')}]` + + const queryParams = new URLSearchParams({ + pids: pidsParam, + timeBucket: params.timeBucket, + period: params.period, + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + filters: serializeFiltersForUrl(params.filters), + includeChart: 'false', + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch>(request, `log/birdseye?${queryParams.toString()}`, { headers }) +} + +interface CustomEventsChartResponse { + chart?: { + events?: Record + } +} + +export async function getCustomEventsDataServer( + request: Request, + pid: string, + params: AnalyticsParams, + customEvents: string[], +): Promise> { + const queryParams = new URLSearchParams({ + pid, + timeBucket: params.timeBucket, + period: params.period, + filters: serializeFiltersForUrl(params.filters), + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + customEvents: JSON.stringify(customEvents), + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/customEvents?${queryParams.toString()}`, { headers }) +} + +export interface PerformanceDataResponse { + params: Record + chart: { + x: string[] + dns: number[] + tls: number[] + conn: number[] + response: number[] + render: number[] + domLoad: number[] + pageLoad: number[] + ttfb: number[] + } + appliedFilters: AnalyticsFilter[] + timeBucket?: string[] +} + +export async function getPerfDataServer( + request: Request, + pid: string, + params: AnalyticsParams & { measure?: string }, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + timeBucket: params.timeBucket, + period: params.period, + filters: serializeFiltersForUrl(params.filters), + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + measure: params.measure || '', + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/performance?${queryParams.toString()}`, { headers }) +} + +export interface PerformanceOverallObject { + current: { frontend: number; backend: number; network: number } + previous: { frontend: number; backend: number; network: number } + frontendChange: number + backendChange: number + networkChange: number +} + +export async function getPerformanceOverallStatsServer( + request: Request, + pids: string[], + params: AnalyticsParams & { measure?: string }, +): Promise>> { + const pidsParam = `[${pids.map((pid) => `"${pid}"`).join(',')}]` + + const queryParams = new URLSearchParams({ + pids: pidsParam, + period: params.period, + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + filters: serializeFiltersForUrl(params.filters), + measure: params.measure || '', + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch>( + request, + `log/performance/birdseye?${queryParams.toString()}`, + { headers }, + ) +} + +interface Session { + psid: string + cc: string | null + os: string | null + br: string | null + pageviews: number + customEvents: number + errors: number + revenue?: number + refunds?: number + created: string + isLive: 1 | 0 + sdur?: number + sessionStart: string + lastActivity: string + profileId: string | null + isIdentified: 1 | 0 + isFirstSession: 1 | 0 +} + +export interface SessionsResponse { + sessions: Session[] + take: number + skip: number + appliedFilters: AnalyticsFilter[] +} + +export async function getSessionsServer( + request: Request, + pid: string, + params: AnalyticsParams & { take?: number; skip?: number }, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + period: params.period, + filters: serializeFiltersForUrl(params.filters), + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + take: String(params.take || 30), + skip: String(params.skip || 0), + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/sessions?${queryParams.toString()}`, { headers }) +} + +interface SwetrixError { + eid: string + name: string + message: string + filename: string + count: number + last_seen: string + status: 'active' | 'regressed' | 'fixed' | 'resolved' + users: number + sessions: number +} + +export interface ErrorsResponse { + errors: SwetrixError[] +} + +export async function getErrorsServer( + request: Request, + pid: string, + params: AnalyticsParams & { take?: number; skip?: number; options?: Record }, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + timeBucket: params.timeBucket, + period: params.period, + filters: serializeFiltersForUrl(params.filters), + options: JSON.stringify(params.options || {}), + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + take: String(params.take || 30), + skip: String(params.skip || 0), + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/errors?${queryParams.toString()}`, { headers }) +} diff --git a/web/app/hooks/useAnalyticsProxy.ts b/web/app/hooks/useAnalyticsProxy.ts new file mode 100644 index 000000000..0024d8d81 --- /dev/null +++ b/web/app/hooks/useAnalyticsProxy.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react' + +import type { SessionsResponse, ErrorsResponse, AnalyticsFilter } from '~/api/api.server' + +interface AnalyticsParams { + timeBucket: string + period: string + from?: string + to?: string + timezone: string + filters?: AnalyticsFilter[] + take?: number + skip?: number + options?: Record +} + +interface ProxyResponse { + data: T | null + error: string | null +} + +export function useSessionsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchSessions = useCallback(async (projectId: string, params: AnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getSessions', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setIsLoading(false) + } + }, []) + + return { fetchSessions, data, error, isLoading } +} + +export function useErrorsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchErrors = useCallback(async (projectId: string, params: AnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getErrors', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setIsLoading(false) + } + }, []) + + return { fetchErrors, data, error, isLoading } +} diff --git a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx index 8556ec75c..d2e0d52c6 100644 --- a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx +++ b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx @@ -16,14 +16,15 @@ import { AlertTriangleIcon, UserIcon, } from 'lucide-react' -import { useState, useEffect, useMemo, useRef, useCallback, lazy, Suspense } from 'react' +import { useState, useEffect, useMemo, useRef, useCallback, lazy, Suspense, use } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useLocation, useSearchParams, useNavigate, useFetcher, type LinkProps } from 'react-router' +import { Link, useLocation, useSearchParams, useNavigate, useFetcher, useLoaderData, useRevalidator, type LinkProps } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' import { getErrors, getErrorOverview, getError, type ErrorOverviewResponse } from '~/api' -import type { ProjectViewActionData } from '~/routes/projects.$id' +import type { ErrorsResponse } from '~/api/api.server' +import type { ProjectViewActionData, ProjectLoaderData } from '~/routes/projects.$id' import { TimeFormat, tbsFormatMapper, @@ -387,9 +388,34 @@ const ErrorItem = ({ error }: ErrorItemProps) => { const ERRORS_TAKE = 30 -const ErrorsView = () => { +interface DeferredErrorsData { + errorsData: ErrorsResponse | null +} + +function ErrorsDataResolver({ children }: { children: (data: DeferredErrorsData) => React.ReactNode }) { + const { errorsData: errorsDataPromise } = useLoaderData() + + const errorsData = errorsDataPromise ? use(errorsDataPromise) : null + + return <>{children({ errorsData })} +} + +function ErrorsViewWrapper() { + return ( + }> + {(deferredData) => } + + ) +} + +interface ErrorsViewInnerProps { + deferredData: DeferredErrorsData +} + +const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { const { id, allowedToManage, project } = useCurrentProject() const projectPassword = useProjectPassword(id) + const revalidator = useRevalidator() const { errorsRefreshTrigger, timeBucket, timeFormat, period, dateRange, timezone, filters, size } = useViewProjectContext() const { @@ -409,6 +435,9 @@ const ErrorsView = () => { const tnMapping = typeNameMapping(t) const rotateXAxis = useMemo(() => size.width > 0 && size.width < 500, [size]) + // Initialize state from deferred data + const initialDataProcessed = useRef(false) + const [errorOptions, setErrorOptions] = useState>({ [ERROR_FILTERS_MAPPING.showResolved]: false, }) @@ -417,10 +446,10 @@ const ErrorsView = () => { const [overview, setOverview] = useState(null) const isMountedRef = useRef(true) - const [errorsLoading, setErrorsLoading] = useState(null) - const [errors, setErrors] = useState([]) - const [errorsSkip, setErrorsSkip] = useState(0) - const [canLoadMoreErrors, setCanLoadMoreErrors] = useState(false) + const [errorsLoading, setErrorsLoading] = useState(() => deferredData.errorsData ? false : null) + const [errors, setErrors] = useState(() => deferredData.errorsData?.errors || []) + const [errorsSkip, setErrorsSkip] = useState(() => deferredData.errorsData?.errors?.length || 0) + const [canLoadMoreErrors, setCanLoadMoreErrors] = useState(() => (deferredData.errorsData?.errors?.length || 0) >= ERRORS_TAKE) const errorsRequestIdRef = useRef(0) const activeEID = useMemo(() => searchParams.get('eid'), [searchParams]) @@ -453,6 +482,42 @@ const ErrorsView = () => { } }, []) + // Process deferred data on mount + useEffect(() => { + if (initialDataProcessed.current) return + initialDataProcessed.current = true + + if (deferredData.errorsData) { + const errorsList = deferredData.errorsData.errors || [] + setErrors(errorsList) + setErrorsSkip(ERRORS_TAKE) + setCanLoadMoreErrors(errorsList.length >= ERRORS_TAKE) + } else { + setErrors([]) + setCanLoadMoreErrors(false) + } + setErrorsLoading(false) + }, [deferredData]) + + // Sync state when revalidation completes with new data + useEffect(() => { + if (!initialDataProcessed.current) return + if (revalidator.state === 'idle') { + if (deferredData.errorsData) { + const errorsList = deferredData.errorsData.errors || [] + setErrors(errorsList) + setErrorsSkip(ERRORS_TAKE) + setCanLoadMoreErrors(errorsList.length >= ERRORS_TAKE) + } else { + setErrors([]) + setCanLoadMoreErrors(false) + } + setErrorsLoading(false) + } else if (revalidator.state === 'loading') { + setErrorsLoading(true) + } + }, [revalidator.state, deferredData]) + const loadOverview = useCallback(async () => { if (overviewLoading) return @@ -678,8 +743,7 @@ const ErrorsView = () => { return } - setErrorsSkip(0) - loadErrors(0, true) + revalidator.revalidate() loadOverview() // eslint-disable-next-line react-hooks/exhaustive-deps }, [errorsRefreshTrigger]) @@ -1159,4 +1223,6 @@ const ErrorsView = () => { ) } +const ErrorsView = ErrorsViewWrapper + export default ErrorsView diff --git a/web/app/pages/Project/tabs/Performance/PerformanceView.tsx b/web/app/pages/Project/tabs/Performance/PerformanceView.tsx index 998e42082..135730f90 100644 --- a/web/app/pages/Project/tabs/Performance/PerformanceView.tsx +++ b/web/app/pages/Project/tabs/Performance/PerformanceView.tsx @@ -1,19 +1,16 @@ import cx from 'clsx' -import dayjs from 'dayjs' -import _find from 'lodash/find' import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' import _map from 'lodash/map' import { EyeIcon, PercentIcon } from 'lucide-react' -import { useState, useEffect, useMemo, useRef, lazy, Suspense } from 'react' +import { useState, useEffect, useMemo, lazy, Suspense, use } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' -import { useNavigate, useSearchParams } from 'react-router' -import { toast } from 'sonner' +import { useNavigate, useSearchParams, useLoaderData, useRevalidator } from 'react-router' -import { getPerfData, getPerformanceCompareData, getPerformanceOverallStats, getOverallStats } from '~/api' +import type { PerformanceDataResponse, PerformanceOverallObject } from '~/api/api.server' import { useAnnotations } from '~/hooks/useAnnotations' -import { PERFORMANCE_PANELS_ORDER, chartTypes, PERIOD_PAIRS_COMPARE, type TimeBucket } from '~/lib/constants' +import { PERFORMANCE_PANELS_ORDER, chartTypes } from '~/lib/constants' import { CountryEntry } from '~/lib/models/Entry' import { OverallPerformanceObject } from '~/lib/models/Project' import AnnotationModal from '~/modals/AnnotationModal' @@ -28,14 +25,14 @@ import NoEvents from '~/pages/Project/View/components/NoEvents' import { Panel } from '~/pages/Project/View/Panels' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' import { - getFormatDate, panelIconMapping, CHART_METRICS_MAPPING_PERF, CHART_MEASURES_MAPPING_PERF, getDeviceRowMapper, } from '~/pages/Project/View/ViewProject.helpers' -import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import { useCurrentProject } from '~/providers/CurrentProjectProvider' import { useTheme } from '~/providers/ThemeProvider' +import type { ProjectLoaderData } from '~/routes/projects.$id' import Dropdown from '~/ui/Dropdown' import Loader from '~/ui/Loader' import LoadingBar from '~/ui/LoadingBar' @@ -43,109 +40,51 @@ import { getStringFromTime, getTimeFromSeconds } from '~/utils/generic' const InteractiveMap = lazy(() => import('~/pages/Project/View/components/InteractiveMap')) -// Period to compare date mapping -const periodToCompareDate = [ - { - period: '1d', - formula: (date?: Date[]) => - date - ? { from: getFormatDate(dayjs(date[0]).subtract(1, 'day').toDate()), to: getFormatDate(date[0]) } - : { - from: dayjs().subtract(2, 'day').format('YYYY-MM-DD'), - to: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), - }, - }, - { - period: '1h', - formula: () => ({ - from: dayjs().subtract(2, 'hour').format('YYYY-MM-DD HH:mm:ss'), - to: dayjs().subtract(1, 'hour').format('YYYY-MM-DD HH:mm:ss'), - }), - }, - { - period: 'today', - formula: () => ({ from: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), to: dayjs().format('YYYY-MM-DD') }), - }, - { - period: 'yesterday', - formula: () => ({ - from: dayjs().subtract(2, 'day').format('YYYY-MM-DD'), - to: dayjs().subtract(1, 'day').format('YYYY-MM-DD'), - }), - }, - { - period: '7d', - formula: (date?: Date[]) => - date - ? { from: getFormatDate(dayjs(date[0]).subtract(7, 'day').toDate()), to: getFormatDate(date[0]) } - : { - from: dayjs().subtract(14, 'day').format('YYYY-MM-DD'), - to: dayjs().subtract(7, 'day').format('YYYY-MM-DD'), - }, - }, - { - period: '4w', - formula: (date?: Date[]) => - date - ? { from: getFormatDate(dayjs(date[0]).subtract(4, 'week').toDate()), to: getFormatDate(date[0]) } - : { - from: dayjs().subtract(8, 'week').format('YYYY-MM-DD'), - to: dayjs().subtract(4, 'week').format('YYYY-MM-DD'), - }, - }, - { - period: '3M', - formula: (date?: Date[]) => - date - ? { from: getFormatDate(dayjs(date[0]).subtract(3, 'month').toDate()), to: getFormatDate(date[0]) } - : { - from: dayjs().subtract(6, 'month').format('YYYY-MM-DD'), - to: dayjs().subtract(3, 'month').format('YYYY-MM-DD'), - }, - }, - { - period: '12M', - formula: (date?: Date[]) => - date - ? { from: getFormatDate(dayjs(date[0]).subtract(12, 'month').toDate()), to: getFormatDate(date[0]) } - : { - from: dayjs().subtract(24, 'month').format('YYYY-MM-DD'), - to: dayjs().subtract(12, 'month').format('YYYY-MM-DD'), - }, - }, - { - period: '24M', - formula: (date?: Date[]) => - date - ? { from: getFormatDate(dayjs(date[0]).subtract(24, 'month').toDate()), to: getFormatDate(date[0]) } - : { - from: dayjs().subtract(48, 'month').format('YYYY-MM-DD'), - to: dayjs().subtract(24, 'month').format('YYYY-MM-DD'), - }, - }, -] - interface PerformanceViewProps { tnMapping: Record } -const PerformanceView = ({ tnMapping }: PerformanceViewProps) => { +interface DeferredPerfData { + perfData: PerformanceDataResponse | null + perfOverallStats: Record | null +} + +function PerfDataResolver({ children }: { children: (data: DeferredPerfData) => React.ReactNode }) { + const { perfData: perfDataPromise, perfOverallStats: perfOverallStatsPromise } = useLoaderData() + + const perfData = perfDataPromise ? use(perfDataPromise) : null + const perfOverallStats = perfOverallStatsPromise ? use(perfOverallStatsPromise) : null + + return <>{children({ perfData, perfOverallStats })} +} + +function PerformanceViewWrapper(props: PerformanceViewProps) { + return ( + }> + + {(deferredData) => } + + + ) +} + +interface PerformanceViewInnerProps extends PerformanceViewProps { + deferredData: DeferredPerfData +} + +const PerformanceViewInner = ({ tnMapping, deferredData }: PerformanceViewInnerProps) => { const { id, project, allowedToManage } = useCurrentProject() - const projectPassword = useProjectPassword(id) + const revalidator = useRevalidator() const { performanceRefreshTrigger, timezone, - period, - dateRange, filters, timeFormat, timeBucket, activePeriod, - // Comparison state from context + // Comparison state from context (not implemented via SSR yet) isActiveCompare, - dateRangeCompare, activePeriodCompare, - compareDisable, // Chart state from context chartType, setChartTypeOnClick, @@ -186,19 +125,65 @@ const PerformanceView = ({ tnMapping }: PerformanceViewProps) => { const [searchParams, setSearchParams] = useSearchParams() const isEmbedded = searchParams.get('embedded') === 'true' - // Performance-specific state + // State for data - initialized from loader, can be overridden by client-side fetches const [dataLoading, setDataLoading] = useState(false) - const [analyticsLoading, setAnalyticsLoading] = useState(true) - const [isPanelsDataEmpty, setIsPanelsDataEmpty] = useState(false) - const [panelsData, setPanelsData] = useState({}) - const [chartData, setChartData] = useState({}) - const [overall, setOverall] = useState>({}) - const [overallCompare, setOverallCompare] = useState>({}) - const [chartDataCompare, setChartDataCompare] = useState({}) - - // Chart metrics and measures - const [activeChartMetrics, setActiveChartMetrics] = useState(CHART_METRICS_MAPPING_PERF.timing) - const [activeMeasure, setActiveMeasure] = useState(CHART_MEASURES_MAPPING_PERF.median) + const [panelsData, setPanelsData] = useState(() => { + if (deferredData.perfData?.params) { + return { + types: _keys(deferredData.perfData.params), + data: deferredData.perfData.params, + } + } + return { data: {} } + }) + const [chartData, setChartData] = useState(() => deferredData.perfData?.chart || {}) + const [overall, setOverall] = useState>(() => { + if (deferredData.perfOverallStats && id) { + return deferredData.perfOverallStats[id] || {} + } + return {} + }) + + const isPanelsDataEmpty = _isEmpty(panelsData.data) + + // Sync state when loader provides new data (e.g., after URL changes) + useEffect(() => { + if (revalidator.state === 'idle' && deferredData.perfData) { + const { chart, params } = deferredData.perfData + setChartData(chart || {}) + setPanelsData({ + types: _keys(params), + data: params, + }) + if (deferredData.perfOverallStats && id) { + setOverall(deferredData.perfOverallStats[id] || {}) + } + setDataLoading(false) + } else if (revalidator.state === 'loading') { + setDataLoading(true) + } + }, [revalidator.state, deferredData, id]) + + // Get chart metrics and measure from URL params (SSR-friendly) + const activeChartMetrics = searchParams.get('perfMetric') || CHART_METRICS_MAPPING_PERF.timing + const activeMeasure = searchParams.get('measure') || CHART_MEASURES_MAPPING_PERF.median + + // Update URL when changing chart metrics or measure + const setActiveChartMetrics = (metric: string) => { + const newParams = new URLSearchParams(searchParams.toString()) + newParams.set('perfMetric', metric) + setSearchParams(newParams) + } + + const setActiveMeasure = (measure: string) => { + const newParams = new URLSearchParams(searchParams.toString()) + newParams.set('measure', measure) + setSearchParams(newParams) + } + + // Compare mode not implemented via SSR yet - use empty defaults + const overallCompare: Partial = {} + const chartDataCompare: any = {} // Panel active tabs const [activeTabs, setActiveTabs] = useState<{ @@ -211,16 +196,6 @@ const PerformanceView = ({ tnMapping }: PerformanceViewProps) => { device: 'br', }) - const isMountedRef = useRef(true) - - // Cleanup on unmount - useEffect(() => { - isMountedRef.current = true - return () => { - isMountedRef.current = false - } - }, []) - const chartMetrics = useMemo( () => [ { @@ -322,202 +297,14 @@ const PerformanceView = ({ tnMapping }: PerformanceViewProps) => { return { browserVersions } }, [panelsData.data?.brv]) - const loadAnalytics = async () => { - if (!project) return - - setDataLoading(true) - - try { - let dataPerf: { timeBucket?: TimeBucket[]; params?: any; chart?: any } - let from - let to - let dataCompare - let fromCompare: string | undefined - let toCompare: string | undefined - let rawOverall: any - - const measure = - activeChartMetrics === CHART_METRICS_MAPPING_PERF.quantiles - ? CHART_METRICS_MAPPING_PERF.quantiles - : activeMeasure - - if (isActiveCompare) { - if (dateRangeCompare && activePeriodCompare === PERIOD_PAIRS_COMPARE.CUSTOM) { - let start - let end - let diff - const startCompare = dayjs.utc(dateRangeCompare[0]) - const endCompare = dayjs.utc(dateRangeCompare[1]) - const diffCompare = endCompare.diff(startCompare, 'day') - - if (activePeriod?.period === 'custom' && dateRange) { - start = dayjs.utc(dateRange[0]) - end = dayjs.utc(dateRange[1]) - diff = end.diff(start, 'day') - } - - // @ts-expect-error - if (activePeriod?.period === 'custom' ? diffCompare <= diff : diffCompare <= activePeriod?.countDays) { - fromCompare = getFormatDate(dateRangeCompare[0]) - toCompare = getFormatDate(dateRangeCompare[1]) - } else { - toast.error(t('project.compareDateRangeError')) - compareDisable() - } - } else { - let date - if (dateRange) { - date = _find(periodToCompareDate, (item) => item.period === period)?.formula(dateRange) - } else { - date = _find(periodToCompareDate, (item) => item.period === period)?.formula() - } - - if (date) { - fromCompare = date.from - toCompare = date.to - } - } - - if (!_isEmpty(fromCompare) && !_isEmpty(toCompare)) { - dataCompare = await getPerformanceCompareData( - id, - timeBucket, - '', - filters, - fromCompare, - toCompare, - timezone, - measure, - projectPassword, - ) - const compareOverall = await getPerformanceOverallStats( - [id], - 'custom', - fromCompare, - toCompare, - timezone, - filters, - measure, - projectPassword, - ) - dataCompare.overall = compareOverall[id] - } - } - - if (dateRange) { - from = getFormatDate(dateRange[0]) - to = getFormatDate(dateRange[1]) - } - - if (period === 'custom' && dateRange) { - dataPerf = await getPerfData(id, timeBucket, '', filters, from, to, timezone, measure, projectPassword) - rawOverall = await getOverallStats([id], timeBucket, period, from, to, timezone, filters, projectPassword) - } else { - dataPerf = await getPerfData(id, timeBucket, period, filters, '', '', timezone, measure, projectPassword) - rawOverall = await getPerformanceOverallStats([id], period, '', '', timezone, filters, measure, projectPassword) - } - - // @ts-expect-error - dataPerf.overall = rawOverall[id] - - if (isMountedRef.current) { - setOverall(rawOverall[id]) - - if (_keys(dataPerf).length < 2) { - setIsPanelsDataEmpty(true) - setDataLoading(false) - setAnalyticsLoading(false) - return - } - - let newTimebucket = timeBucket - - if (period === 'all' && !_isEmpty(dataPerf.timeBucket)) { - newTimebucket = dataPerf.timeBucket?.includes(timeBucket as TimeBucket) - ? timeBucket - : (dataPerf.timeBucket?.[0] as TimeBucket) - const newSearchParams = new URLSearchParams(searchParams.toString()) - newSearchParams.set('timeBucket', newTimebucket) - setSearchParams(newSearchParams) - } - - if (!_isEmpty(dataCompare)) { - if (!_isEmpty(dataCompare?.chart)) { - setChartDataCompare(dataCompare.chart) - } - - if (!_isEmpty(dataCompare?.overall)) { - setOverallCompare(dataCompare.overall) - } - } - - if (_isEmpty(dataPerf.params)) { - setIsPanelsDataEmpty(true) - } else { - const { chart: chartPerf } = dataPerf - setChartData(chartPerf) - - setPanelsData({ - types: _keys(dataPerf.params), - data: dataPerf.params, - }) - - setIsPanelsDataEmpty(false) - } - - setAnalyticsLoading(false) - setDataLoading(false) - } - } catch (reason) { - if (isMountedRef.current) { - setAnalyticsLoading(false) - setDataLoading(false) - setIsPanelsDataEmpty(true) - console.error('[ERROR](loadAnalytics) Loading analytics data failed:', reason) - } - } - } - - // Load analytics on mount and when dependencies change - useEffect(() => { - if (!project) return - loadAnalytics() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - activeMeasure, - activeChartMetrics, - filters, - project, - isActiveCompare, - dateRange, - period, - timeBucket, - dateRangeCompare, - activePeriodCompare, - ]) - // Handle refresh trigger useEffect(() => { if (performanceRefreshTrigger > 0) { - loadAnalytics() + revalidator.revalidate() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [performanceRefreshTrigger]) - // Show loader during initial load - if (analyticsLoading) { - return ( -
- -
- ) - } - // Show no events if data is empty if (isPanelsDataEmpty) { return ( @@ -615,7 +402,7 @@ const PerformanceView = ({ tnMapping }: PerformanceViewProps) => { <> {dataLoading && !isPanelsDataEmpty ? : null} -
+
{!isPanelsDataEmpty ? : null}
@@ -893,4 +680,6 @@ const PerformanceView = ({ tnMapping }: PerformanceViewProps) => { ) } +const PerformanceView = PerformanceViewWrapper + export default PerformanceView diff --git a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx index 6e46c559a..08e130387 100644 --- a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx +++ b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx @@ -1,11 +1,13 @@ import cx from 'clsx' import _isEmpty from 'lodash/isEmpty' import { DownloadIcon } from 'lucide-react' -import { useState, useEffect, useMemo, useRef } from 'react' +import { useState, useEffect, useMemo, useRef, Suspense, use } from 'react' import { useTranslation } from 'react-i18next' -import { useSearchParams } from 'react-router' +import { useSearchParams, useLoaderData, useRevalidator } from 'react-router' import { getSessions, getSession } from '~/api' +import type { SessionsResponse } from '~/api/api.server' +import type { ProjectLoaderData } from '~/routes/projects.$id' import { Session, SessionDetails as SessionDetailsType } from '~/lib/models/Project' import NoSessions from '~/pages/Project/tabs/Sessions/components/NoSessions' import { SessionDetailView } from '~/pages/Project/tabs/Sessions/SessionDetailView' @@ -47,16 +49,46 @@ interface SessionsViewProps { rotateXAxis: boolean } -const SessionsView = ({ tnMapping, rotateXAxis }: SessionsViewProps) => { +interface DeferredSessionsData { + sessionsData: SessionsResponse | null +} + +function SessionsDataResolver({ children }: { children: (data: DeferredSessionsData) => React.ReactNode }) { + const { sessionsData: sessionsDataPromise } = useLoaderData() + + const sessionsData = sessionsDataPromise ? use(sessionsDataPromise) : null + + return <>{children({ sessionsData })} +} + +function SessionsViewWrapper(props: SessionsViewProps) { + return ( + }> + + {(deferredData) => } + + + ) +} + +interface SessionsViewInnerProps extends SessionsViewProps { + deferredData: DeferredSessionsData +} + +const SessionsViewInner = ({ tnMapping, rotateXAxis, deferredData }: SessionsViewInnerProps) => { const { id, project } = useCurrentProject() const projectPassword = useProjectPassword(id) + const revalidator = useRevalidator() const { sessionsRefreshTrigger, timezone, period, dateRange, filters, timeFormat } = useViewProjectContext() const { t } = useTranslation('common') const [searchParams] = useSearchParams() + // Initialize state from deferred data + const initialDataProcessed = useRef(false) + // Session list state - const [sessions, setSessions] = useState([]) - const [sessionsLoading, setSessionsLoading] = useState(null) + const [sessions, setSessions] = useState(() => deferredData.sessionsData?.sessions || []) + const [sessionsLoading, setSessionsLoading] = useState(() => deferredData.sessionsData ? false : null) const [sessionsSkip, setSessionsSkip] = useState(0) const [canLoadMoreSessions, setCanLoadMoreSessions] = useState(false) const sessionsRequestIdRef = useRef(0) @@ -99,6 +131,42 @@ const SessionsView = ({ tnMapping, rotateXAxis }: SessionsViewProps) => { } }, []) + // Process deferred data on mount + useEffect(() => { + if (initialDataProcessed.current) return + initialDataProcessed.current = true + + if (deferredData.sessionsData) { + const sessionsList = deferredData.sessionsData.sessions || [] + setSessions(sessionsList) + setSessionsSkip(SESSIONS_TAKE) + setCanLoadMoreSessions(sessionsList.length >= SESSIONS_TAKE) + } else { + setSessions([]) + setCanLoadMoreSessions(false) + } + setSessionsLoading(false) + }, [deferredData]) + + // Sync state when revalidation completes with new data + useEffect(() => { + if (!initialDataProcessed.current) return + if (revalidator.state === 'idle') { + if (deferredData.sessionsData) { + const sessionsList = deferredData.sessionsData.sessions || [] + setSessions(sessionsList) + setSessionsSkip(SESSIONS_TAKE) + setCanLoadMoreSessions(sessionsList.length >= SESSIONS_TAKE) + } else { + setSessions([]) + setCanLoadMoreSessions(false) + } + setSessionsLoading(false) + } else if (revalidator.state === 'loading') { + setSessionsLoading(true) + } + }, [revalidator.state, deferredData]) + // Reset sessions when filters change useEffect(() => { sessionsRequestIdRef.current += 1 @@ -228,8 +296,7 @@ const SessionsView = ({ tnMapping, rotateXAxis }: SessionsViewProps) => { if (activePSID) { loadSession(activePSID) } else { - setSessionsSkip(0) - loadSessions(0, true) + revalidator.revalidate() } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -308,4 +375,6 @@ const SessionsView = ({ tnMapping, rotateXAxis }: SessionsViewProps) => { ) } +const SessionsView = SessionsViewWrapper + export default SessionsView diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index d7ee86bc5..3993f45c0 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -1,32 +1,30 @@ import cx from 'clsx' -import dayjs from 'dayjs' import _filter from 'lodash/filter' -import _find from 'lodash/find' import _includes from 'lodash/includes' import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' import _map from 'lodash/map' import _some from 'lodash/some' import { BanIcon, ChartColumnBigIcon, ChartLineIcon, EyeIcon } from 'lucide-react' -import React, { useState, useEffect, useMemo, useRef, lazy, Suspense, useCallback } from 'react' +import React, { useState, useEffect, useMemo, useRef, lazy, Suspense, useCallback, use } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' -import { useNavigate, Link, useSearchParams } from 'react-router' +import { useNavigate, Link, useSearchParams, useLoaderData, useRevalidator } from 'react-router' import { toast } from 'sonner' import { - getProjectData, - getOverallStats, - getProjectDataCustomEvents, - getTrafficCompareData, getCustomEventsMetadata, getPropertyMetadata, getGSCKeywords, getRevenueData, getRevenueStatus, } from '~/api' +import type { + TrafficLogResponse as ServerTrafficLogResponse, + OverallObject as ServerOverallObject, +} from '~/api/api.server' import { useAnnotations } from '~/hooks/useAnnotations' -import { TRAFFIC_PANELS_ORDER, chartTypes, PERIOD_PAIRS_COMPARE, isSelfhosted, type TimeBucket } from '~/lib/constants' +import { TRAFFIC_PANELS_ORDER, chartTypes, isSelfhosted } from '~/lib/constants' import { CountryEntry, Entry } from '~/lib/models/Entry' import { OverallObject } from '~/lib/models/Project' import AnnotationModal from '~/modals/AnnotationModal' @@ -51,7 +49,6 @@ import { ProjectView, ProjectViewCustomEvent, Properties, - TrafficLogResponse, } from '~/pages/Project/View/interfaces/traffic' import { Panel, CustomEvents, MetadataKeyPanel } from '~/pages/Project/View/Panels' import { FILTER_CHART_METRICS_MAPPING_FOR_COMPARE } from '~/pages/Project/View/utils/filters' @@ -66,11 +63,11 @@ import { } from '~/pages/Project/View/ViewProject.helpers' import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' import { useTheme } from '~/providers/ThemeProvider' +import type { ProjectLoaderData } from '~/routes/projects.$id' import Checkbox from '~/ui/Checkbox' import Dropdown from '~/ui/Dropdown' import Loader from '~/ui/Loader' import LoadingBar from '~/ui/LoadingBar' -import { periodToCompareDate } from '~/utils/compareConvertDate' import { getLocaleDisplayName, nLocaleFormatter } from '~/utils/generic' import { groupRefEntries } from '~/utils/referrers' import routes from '~/utils/routes' @@ -102,7 +99,47 @@ interface TrafficViewProps { onCustomMetric: (metrics: ProjectViewCustomEvent[]) => void } -const TrafficView = ({ +interface DeferredTrafficData { + trafficData: ServerTrafficLogResponse | null + overallStats: Record | null + trafficCompareData: ServerTrafficLogResponse | null + overallCompareStats: Record | null + customEventsData: { chart?: { events?: Record } } | null +} + +function TrafficDataResolver({ children }: { children: (data: DeferredTrafficData) => React.ReactNode }) { + const { + trafficData: trafficDataPromise, + overallStats: overallStatsPromise, + trafficCompareData: trafficComparePromise, + overallCompareStats: overallComparePromise, + customEventsData: customEventsPromise, + } = useLoaderData() + + const trafficData = trafficDataPromise ? use(trafficDataPromise) : null + const overallStats = overallStatsPromise ? use(overallStatsPromise) : null + const trafficCompareData = trafficComparePromise ? use(trafficComparePromise) : null + const overallCompareStats = overallComparePromise ? use(overallComparePromise) : null + const customEventsData = customEventsPromise ? use(customEventsPromise) : null + + return <>{children({ trafficData, overallStats, trafficCompareData, overallCompareStats, customEventsData })} +} + +function TrafficViewWrapper(props: TrafficViewProps) { + return ( + }> + + {(deferredData) => } + + + ) +} + +interface TrafficViewInnerProps extends TrafficViewProps { + deferredData: DeferredTrafficData +} + +const TrafficViewInner = ({ tnMapping, customMetrics, onRemoveCustomMetric, @@ -114,9 +151,11 @@ const TrafficView = ({ setProjectViewToUpdate, setIsAddAViewOpened, onCustomMetric, -}: TrafficViewProps) => { + deferredData, +}: TrafficViewInnerProps) => { const { id, project, allowedToManage } = useCurrentProject() const projectPassword = useProjectPassword(id) + const revalidator = useRevalidator() const { trafficRefreshTrigger, timezone, @@ -171,17 +210,62 @@ const TrafficView = ({ closeContextMenu, } = useAnnotations() - // Traffic-specific state - const [dataLoading, setDataLoading] = useState(false) - const [analyticsLoading, setAnalyticsLoading] = useState(true) - const [isPanelsDataEmpty, setIsPanelsDataEmpty] = useState(false) - const [panelsData, setPanelsData] = useState({ data: {} }) - const [chartData, setChartData] = useState({}) - const [overall, setOverall] = useState>({}) - const [overallCompare, setOverallCompare] = useState>({}) - const [dataChartCompare, setDataChartCompare] = useState({}) - const [customEventsChartData, setCustomEventsChartData] = useState({}) + // Derive base data directly from loader - no need to copy to state + const basePanelsData: PanelsData = useMemo(() => { + if (deferredData.trafficData?.params) { + return { + types: _keys(deferredData.trafficData.params), + data: deferredData.trafficData.params, + customs: deferredData.trafficData.customs, + properties: deferredData.trafficData.properties, + meta: deferredData.trafficData.meta, + } + } + return { data: {} } + }, [deferredData.trafficData]) + + const baseChartData = useMemo(() => deferredData.trafficData?.chart || {}, [deferredData.trafficData]) + + const overall: Partial = useMemo(() => { + if (deferredData.overallStats && id) { + return deferredData.overallStats[id] || {} + } + return {} + }, [deferredData.overallStats, id]) + + const dataLoading = revalidator.state === 'loading' + + // Derive compare data from loader + const overallCompare: Partial = useMemo(() => { + if (deferredData.overallCompareStats && id) { + return deferredData.overallCompareStats[id] || {} + } + return {} + }, [deferredData.overallCompareStats, id]) + + const dataChartCompare = useMemo(() => deferredData.trafficCompareData?.chart || {}, [deferredData.trafficCompareData]) + + // Derive custom events data from loader + const customEventsChartData = useMemo( + () => (deferredData.customEventsData?.chart?.events || {}) as Record, + [deferredData.customEventsData], + ) + + // Revenue state (still fetched client-side for now) const [isRevenueConnected, setIsRevenueConnected] = useState(false) + const [revenueOverlay, setRevenueOverlay] = useState<{ revenue: number[]; refundsAmount: number[] } | null>(null) + + // Merge base chart with revenue data + const chartData = useMemo(() => { + if (revenueOverlay) { + return { ...baseChartData, ...revenueOverlay } + } + return baseChartData + }, [baseChartData, revenueOverlay]) + + // Use base panels data (no client-side modifications needed for panels) + const panelsData = basePanelsData + const isPanelsDataEmpty = useMemo(() => _isEmpty(panelsData.data), [panelsData.data]) // Chart metrics state const [activeChartMetrics, setActiveChartMetrics] = useState({ @@ -195,7 +279,11 @@ const TrafficView = ({ [CHART_METRICS_MAPPING.customEvents]: false, [CHART_METRICS_MAPPING.revenue]: false, }) - const [activeChartMetricsCustomEvents, setActiveChartMetricsCustomEvents] = useState([]) + // Get custom events from URL params (SSR-friendly) + const activeChartMetricsCustomEvents = useMemo(() => { + const param = searchParams.get('customEvents') + return param ? param.split(',').filter(Boolean) : [] + }, [searchParams]) // Panel active tabs const [panelsActiveTabs, setPanelsActiveTabs] = useState<{ @@ -407,14 +495,27 @@ const TrafficView = ({ [isConflicted, t], ) - const switchCustomEventChart = useCallback((id: string) => { - setActiveChartMetricsCustomEvents((prev) => { - if (_includes(prev, id)) { - return _filter(prev, (item) => item !== id) + const switchCustomEventChart = useCallback( + (eventId: string) => { + const newParams = new URLSearchParams(searchParams.toString()) + const currentEvents = newParams.get('customEvents')?.split(',').filter(Boolean) || [] + + let newEvents: string[] + if (currentEvents.includes(eventId)) { + newEvents = currentEvents.filter((e) => e !== eventId) + } else { + newEvents = [...currentEvents, eventId] } - return [...prev, id] - }) - }, []) + + if (newEvents.length > 0) { + newParams.set('customEvents', newEvents.join(',')) + } else { + newParams.delete('customEvents') + } + setSearchParams(newParams) + }, + [searchParams, setSearchParams], + ) const setPanelTab = (panel: keyof typeof panelsActiveTabs, tab: string) => { setPanelsActiveTabs((prev) => ({ @@ -423,280 +524,71 @@ const TrafficView = ({ })) } - const loadAnalytics = async () => { - if (!project) return - - setDataLoading(true) + // Load revenue data (the only remaining client-side fetch) + const loadRevenueData = useCallback(async () => { + if (!project || !activeChartMetrics.revenue || isSelfhosted || !isRevenueConnected) { + setRevenueOverlay(null) + return + } try { - let data: TrafficLogResponse & { overall?: OverallObject } - let dataCompare: (TrafficLogResponse & { overall?: OverallObject }) | null = null - let from - let fromCompare: string | undefined - let to - let toCompare: string | undefined - let customEventsChart = customEventsChartData - let rawOverall: any - - if (isActiveCompare) { - if (dateRangeCompare && activePeriodCompare === PERIOD_PAIRS_COMPARE.CUSTOM) { - let start - let end - let diff - const startCompare = dayjs.utc(dateRangeCompare[0]) - const endCompare = dayjs.utc(dateRangeCompare[1]) - const diffCompare = endCompare.diff(startCompare, 'day') - - if (activePeriod?.period === 'custom' && dateRange) { - start = dayjs.utc(dateRange[0]) - end = dayjs.utc(dateRange[1]) - diff = end.diff(start, 'day') - } - - // @ts-expect-error - if (activePeriod?.period === 'custom' ? diffCompare <= diff : diffCompare <= activePeriod?.countDays) { - fromCompare = getFormatDate(dateRangeCompare[0]) - toCompare = getFormatDate(dateRangeCompare[1]) - } else { - toast.error(t('project.compareDateRangeError')) - compareDisable() - } - } else { - let date - if (dateRange) { - date = _find(periodToCompareDate, (item) => item.period === period)?.formula(dateRange) - } else { - date = _find(periodToCompareDate, (item) => item.period === period)?.formula() - } - - if (date) { - fromCompare = date.from - toCompare = date.to - } - } - - if (!_isEmpty(fromCompare) && !_isEmpty(toCompare)) { - dataCompare = - (await getTrafficCompareData( - id, - timeBucket, - '', - filters, - fromCompare, - toCompare, - timezone, - projectPassword, - mode, - )) || {} - const compareOverall = await getOverallStats( - [id], - timeBucket, - 'custom', - fromCompare, - toCompare, - timezone, - filters, - projectPassword, - ) - - // @ts-expect-error - dataCompare.overall = compareOverall[id] - } - } + let from: string | undefined + let to: string | undefined if (dateRange) { from = getFormatDate(dateRange[0]) to = getFormatDate(dateRange[1]) } - if (period === 'custom' && dateRange) { - data = await getProjectData( - id, - timeBucket, - '', - filters, - customMetrics, - from, - to, - timezone, - projectPassword, - mode, - ) - if (activeChartMetricsCustomEvents.length > 0) { - customEventsChart = await getProjectDataCustomEvents( - id, - timeBucket, - '', - filters, - from, - to, - timezone, - activeChartMetricsCustomEvents, - projectPassword, - ) - } - rawOverall = await getOverallStats([id], timeBucket, period, from, to, timezone, filters, projectPassword) - } else { - data = await getProjectData( - id, - timeBucket, - period, - filters, - customMetrics, - '', - '', - timezone, - projectPassword, - mode, - ) - if (activeChartMetricsCustomEvents.length > 0) { - customEventsChart = await getProjectDataCustomEvents( - id, - timeBucket, - period, - filters, - '', - '', - timezone, - activeChartMetricsCustomEvents, - projectPassword, - ) - } - rawOverall = await getOverallStats([id], timeBucket, period, '', '', timezone, filters, projectPassword) - } - - customEventsChart = customEventsChart?.chart ? customEventsChart.chart.events : customEventsChartData - - if (isMountedRef.current) { - setCustomEventsChartData(customEventsChart) - data.overall = rawOverall[id] - setOverall(rawOverall[id]) - - if (_keys(data).length < 2) { - setAnalyticsLoading(false) - setDataLoading(false) - setIsPanelsDataEmpty(true) - return - } - - const { chart, params, customs, properties, meta } = data - let newTimebucket = timeBucket - - if (period === 'all' && !_isEmpty(data.timeBucket)) { - newTimebucket = _includes(data.timeBucket, timeBucket) ? timeBucket : (data.timeBucket?.[0] as TimeBucket) - - const newSearchParams = new URLSearchParams(searchParams.toString()) - newSearchParams.set('timeBucket', newTimebucket) - setSearchParams(newSearchParams) - } - - if (!_isEmpty(dataCompare)) { - if (!_isEmpty(dataCompare?.chart)) { - setDataChartCompare(dataCompare.chart) - } - - if (!_isEmpty(dataCompare?.overall)) { - setOverallCompare(dataCompare.overall) - } - } - - if (_isEmpty(params)) { - setIsPanelsDataEmpty(true) + const revResult = await getRevenueData( + id, + period === 'custom' ? 'custom' : period, + from, + to, + timezone, + timeBucket, + ) + const revChart = revResult?.chart + const revX = revChart?.x || [] + const revY = revChart?.revenue || [] + const revRefunds = revChart?.refundsAmount || [] + + // Align revenue series to the main traffic chart x-axis + const chart = baseChartData as { x?: string[] } + let revenueData: number[] = [] + let refundsData: number[] = [] + + if (Array.isArray(chart?.x) && chart.x.length > 0 && Array.isArray(revX) && revX.length > 0) { + if (revX.length === chart.x.length && revX[0] === chart.x[0]) { + revenueData = revY.map((v: any) => Number(v ?? 0)) + refundsData = revRefunds.map((v: any) => Number(v ?? 0)) } else { - // Fetch revenue data if revenue metric is enabled and not selfhosted - let revenueData: number[] = [] - let refundsData: number[] = [] - if (activeChartMetrics.revenue && !isSelfhosted && isRevenueConnected) { - try { - const revResult = await getRevenueData( - id, - period === 'custom' ? 'custom' : period, - period === 'custom' && dateRange ? getFormatDate(dateRange[0]) : undefined, - period === 'custom' && dateRange ? getFormatDate(dateRange[1]) : undefined, - timezone, - timeBucket, - ) - const revChart = revResult?.chart - const revX = revChart?.x || [] - const revY = revChart?.revenue || [] - const revRefunds = revChart?.refundsAmount || [] - - // Align revenue series to the main traffic chart x-axis to avoid empty/invalid renders - if (Array.isArray(chart?.x) && chart.x.length > 0) { - if ( - Array.isArray(revX) && - revX.length === chart.x.length && - revX[0] === chart.x[0] && - revX[revX.length - 1] === chart.x[chart.x.length - 1] - ) { - revenueData = revY.map((v) => Number(v ?? 0)) - refundsData = revRefunds.map((v) => Number(v ?? 0)) - } else if (Array.isArray(revX) && revX.length > 0) { - const byX = new Map() - const refundsByX = new Map() - for (let i = 0; i < revX.length; i += 1) { - byX.set(revX[i], Number(revY[i] ?? 0)) - refundsByX.set(revX[i], Number(revRefunds[i] ?? 0)) - } - revenueData = chart.x.map((x: string) => Number(byX.get(x) ?? 0)) - refundsData = chart.x.map((x: string) => Number(refundsByX.get(x) ?? 0)) - - // Fallback: if mapping produced all zeros but arrays match length, use index-based mapping - if (revenueData.every((v) => v === 0) && revY.length === chart.x.length) { - console.warn( - '[Revenue] Key-based mapping failed, falling back to index-based alignment. Data may be misaligned.', - ) - revenueData = revY.map((v) => Number(v ?? 0)) - refundsData = revRefunds.map((v) => Number(v ?? 0)) - } - } else { - revenueData = revY.map((v) => Number(v ?? 0)) - refundsData = revRefunds.map((v) => Number(v ?? 0)) - } - } else { - revenueData = revY.map((v) => Number(v ?? 0)) - refundsData = revRefunds.map((v) => Number(v ?? 0)) - } - } catch { - // Revenue data not available, continue without it - revenueData = [] - refundsData = [] - } - } - - // Merge revenue data with chart - const chartWithRevenue = { - ...chart, - revenue: revenueData, - refundsAmount: refundsData, + const byX = new Map() + const refundsByX = new Map() + for (let i = 0; i < revX.length; i += 1) { + byX.set(revX[i], Number(revY[i] ?? 0)) + refundsByX.set(revX[i], Number(revRefunds[i] ?? 0)) } - - setChartData(chartWithRevenue as any) - - setPanelsData({ - types: _keys(params), - data: params, - customs, - properties, - meta, - }) - - setIsPanelsDataEmpty(false) + revenueData = chart.x.map((x: string) => Number(byX.get(x) ?? 0)) + refundsData = chart.x.map((x: string) => Number(refundsByX.get(x) ?? 0)) } - - setAnalyticsLoading(false) - setDataLoading(false) - } - } catch (reason) { - if (isMountedRef.current) { - setAnalyticsLoading(false) - setDataLoading(false) - setIsPanelsDataEmpty(true) - console.error('[ERROR](loadAnalytics) Loading analytics data failed') - console.error(reason) } + + setRevenueOverlay({ revenue: revenueData, refundsAmount: refundsData }) + } catch { + setRevenueOverlay(null) } - } + }, [ + project, + activeChartMetrics.revenue, + isRevenueConnected, + dateRange, + period, + timezone, + timeBucket, + baseChartData, + id, + ]) const getCustomEventMetadata = async (event: string) => { if (period === 'custom' && dateRange) { @@ -784,41 +676,15 @@ const TrafficView = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [panelsActiveTabs.source, period, dateRange, timezone, id, project]) - // Load analytics on mount and when dependencies change + // Load revenue data when revenue metric is enabled useEffect(() => { - if (!project) return - loadAnalytics() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - activeChartMetrics.revenue, - isRevenueConnected, - mode, - customMetrics, - filters, - project, - isActiveCompare, - dateRange, - period, - timeBucket, - dateRangeCompare, - activePeriodCompare, - ]) - - // Handle custom events chart loading - useEffect(() => { - if (!project) return - if (activeChartMetricsCustomEvents.length === 0) { - setCustomEventsChartData({}) - return - } - loadAnalytics() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeChartMetricsCustomEvents]) + loadRevenueData() + }, [loadRevenueData]) - // Handle refresh trigger + // Handle refresh trigger - use revalidator for URL-based data useEffect(() => { if (trafficRefreshTrigger > 0) { - loadAnalytics() + revalidator.revalidate() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [trafficRefreshTrigger]) @@ -839,20 +705,6 @@ const TrafficView = ({ return } - // Show loader during initial load - if (analyticsLoading) { - return ( -
- -
- ) - } - const headerRightContent = ( {dataLoading && !isPanelsDataEmpty ? : null} -
+
{!isPanelsDataEmpty ? : null}
@@ -983,7 +835,7 @@ const TrafficView = ({ return ( ) : null} {!checkIfAllMetricsAreDisabled && !_isEmpty(chartData) ? ( -
handleChartContextMenu(e, chartData.x)} className='relative'> +
handleChartContextMenu(e, (chartData as any).x)} className='relative'> + } +} + +interface ProxyResponse { + data: T | null + error: string | null +} + +export async function action({ request }: ActionFunctionArgs) { + const body = (await request.json()) as ProxyRequest + + const { action, projectId, params } = body + + const password = getProjectPasswordCookie(request, projectId) + + const analyticsParams: AnalyticsParams & { take?: number; skip?: number; options?: Record } = { + timeBucket: params.timeBucket, + period: params.period, + from: params.from, + to: params.to, + timezone: params.timezone, + filters: params.filters || [], + password: password || undefined, + take: params.take, + skip: params.skip, + } + + try { + switch (action) { + case 'getSessions': { + const result = await getSessionsServer(request, projectId, analyticsParams) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getErrors': { + const errorsParams = { ...analyticsParams, options: params.options } + const result = await getErrorsServer(request, projectId, errorsParams) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + default: + return data>({ data: null, error: `Unknown action: ${action}` }, { status: 400 }) + } + } catch (error) { + console.error('[api.analytics] Proxy request failed:', error) + return data>( + { data: null, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export async function loader({ request }: LoaderFunctionArgs) { + return data({ error: 'Use POST method' }, { status: 405 }) +} diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index dcc505317..62a491880 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -1,10 +1,29 @@ +import _includes from 'lodash/includes' +import _some from 'lodash/some' import _split from 'lodash/split' +import _startsWith from 'lodash/startsWith' import { type ActionFunctionArgs, type LinksFunction, type LoaderFunctionArgs, type MetaFunction } from 'react-router' import { data, redirect } from 'react-router' -import { serverFetch } from '~/api/api.server' +import { + serverFetch, + getProjectDataServer, + getOverallStatsServer, + getCustomEventsDataServer, + getPerfDataServer, + getPerformanceOverallStatsServer, + getSessionsServer, + getErrorsServer, + type TrafficLogResponse, + type OverallObject, + type PerformanceDataResponse, + type PerformanceOverallObject, + type SessionsResponse, + type ErrorsResponse, + type AnalyticsFilter, +} from '~/api/api.server' import { useRequiredParams } from '~/hooks/useRequiredParams' -import { API_URL, MAIN_URL } from '~/lib/constants' +import { API_URL, MAIN_URL, DEFAULT_TIMEZONE, PROJECT_TABS } from '~/lib/constants' import { Project } from '~/lib/models/Project' import ViewProject from '~/pages/Project/View' import { CurrentProjectProvider } from '~/providers/CurrentProjectProvider' @@ -31,10 +50,107 @@ export const meta: MetaFunction = ({ location }) => { ] } +const validFilters = [ + 'host', + 'cc', + 'rg', + 'ct', + 'pg', + 'entryPage', + 'exitPage', + 'lc', + 'ref', + 'refn', + 'dv', + 'br', + 'brv', + 'os', + 'osv', + 'so', + 'me', + 'ca', + 'te', + 'co', + 'ev', + 'tag:key', + 'tag:value', + 'ev:key', + 'ev:value', +] +const validDynamicFilters = ['ev:key:', 'tag:key:'] + +function isFilterValid(filter: string, checkDynamicFilters = false) { + let normalised = filter + if (_startsWith(normalised, '!') || _startsWith(normalised, '~') || _startsWith(normalised, '^')) { + normalised = normalised.substring(1) + } + + if (_includes(validFilters, normalised)) { + return true + } + + if (checkDynamicFilters && _some(validDynamicFilters, (prefix) => _startsWith(normalised, prefix))) { + return true + } + + return false +} + +function parseFiltersFromUrl(searchParams: URLSearchParams): AnalyticsFilter[] { + const filters: AnalyticsFilter[] = [] + + const entries = Array.from(searchParams.entries()) + + for (const [key, value] of entries) { + let actualColumn = key + + let isExclusive = false + let isContains = false + + if (key.startsWith('!')) { + isExclusive = true + actualColumn = key.substring(1) + } else if (key.startsWith('~')) { + isContains = true + actualColumn = key.substring(1) + } else if (key.startsWith('^')) { + isExclusive = true + isContains = true + actualColumn = key.substring(1) + } + + if (!isFilterValid(actualColumn, true)) { + continue + } + + filters.push({ + column: actualColumn, + filter: value, + isExclusive: isExclusive, + isContains, + }) + } + + return filters +} + export interface ProjectLoaderData { project: Project | null isPasswordRequired: boolean error?: string + // Traffic data + trafficData?: Promise + overallStats?: Promise | null> + trafficCompareData?: Promise + overallCompareStats?: Promise | null> + customEventsData?: Promise<{ chart?: { events?: Record } } | null> + // Performance data + perfData?: Promise + perfOverallStats?: Promise | null> + // Sessions data + sessionsData?: Promise + // Errors data + errorsData?: Promise } export async function loader({ request, params }: LoaderFunctionArgs) { @@ -77,7 +193,102 @@ export async function loader({ request, params }: LoaderFunctionArgs) { cookies.push(createProjectPasswordCookie(projectId || '', password)) } - return data({ project, isPasswordRequired: false }, { headers: createHeadersWithCookies(cookies) }) + // Parse URL params for analytics data + const tab = (url.searchParams.get('tab') as keyof typeof PROJECT_TABS) || PROJECT_TABS.traffic + const period = url.searchParams.get('period') || '7d' + const timeBucket = url.searchParams.get('timeBucket') || 'day' + const from = url.searchParams.get('from') || '' + const to = url.searchParams.get('to') || '' + const timezone = url.searchParams.get('timezone') || DEFAULT_TIMEZONE + const filters = parseFiltersFromUrl(url.searchParams) + + // Performance-specific params + const measure = url.searchParams.get('measure') || 'median' + const perfMetric = url.searchParams.get('perfMetric') || 'timing' // 'timing' or 'quantiles' + + // Traffic compare params + const compareEnabled = url.searchParams.get('compare') === 'true' + const compareFrom = url.searchParams.get('compareFrom') || '' + const compareTo = url.searchParams.get('compareTo') || '' + + // Traffic custom events params + const customEventsParam = url.searchParams.get('customEvents') || '' + const customEvents = customEventsParam ? customEventsParam.split(',').filter(Boolean) : [] + + const analyticsParams = { + timeBucket, + period, + filters, + from: from || undefined, + to: to || undefined, + timezone, + password: password || undefined, + } + + // Initialize deferred data promises + let trafficData: Promise | undefined + let overallStats: Promise | null> | undefined + let trafficCompareData: Promise | undefined + let overallCompareStats: Promise | null> | undefined + let customEventsData: Promise<{ chart?: { events?: Record } } | null> | undefined + let perfData: Promise | undefined + let perfOverallStats: Promise | null> | undefined + let sessionsData: Promise | undefined + let errorsData: Promise | undefined + + // Only fetch data if project is valid and not locked + if (project && !project.isLocked && projectId) { + if (tab === PROJECT_TABS.traffic) { + trafficData = getProjectDataServer(request, projectId, analyticsParams).then((res) => res.data) + overallStats = getOverallStatsServer(request, [projectId], analyticsParams).then((res) => res.data) + + // Fetch compare data if enabled + if (compareEnabled && compareFrom && compareTo) { + const compareParams = { + ...analyticsParams, + period: 'custom' as const, + from: compareFrom, + to: compareTo, + } + trafficCompareData = getProjectDataServer(request, projectId, compareParams).then((res) => res.data) + overallCompareStats = getOverallStatsServer(request, [projectId], compareParams).then((res) => res.data) + } + + // Fetch custom events data if any custom events are selected + if (customEvents.length > 0) { + customEventsData = getCustomEventsDataServer(request, projectId, analyticsParams, customEvents).then( + (res) => res.data, + ) + } + } else if (tab === PROJECT_TABS.performance) { + // Use measure from URL, or 'quantiles' if perfMetric is quantiles + const effectiveMeasure = perfMetric === 'quantiles' ? 'quantiles' : measure + const perfParams = { ...analyticsParams, measure: effectiveMeasure } + perfData = getPerfDataServer(request, projectId, perfParams).then((res) => res.data) + perfOverallStats = getPerformanceOverallStatsServer(request, [projectId], perfParams).then((res) => res.data) + } else if (tab === PROJECT_TABS.sessions) { + sessionsData = getSessionsServer(request, projectId, analyticsParams).then((res) => res.data) + } else if (tab === PROJECT_TABS.errors) { + errorsData = getErrorsServer(request, projectId, analyticsParams).then((res) => res.data) + } + } + + return data( + { + project, + isPasswordRequired: false, + trafficData, + overallStats, + trafficCompareData, + overallCompareStats, + customEventsData, + perfData, + perfOverallStats, + sessionsData, + errorsData, + }, + { headers: createHeadersWithCookies(cookies) }, + ) } export interface ProjectViewActionData { From de3bfc05ed0ff9c7d44f650a9bf329d8d1a342e7 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Fri, 9 Jan 2026 23:35:25 +0000 Subject: [PATCH 22/57] ff & goals --- web/app/api/api.server.ts | 434 ++++++++++++++++++ web/app/api/index.ts | 403 +--------------- web/app/hooks/useAnalyticsProxy.ts | 137 +++++- .../Project/tabs/Alerts/ProjectAlertsView.tsx | 90 ++-- .../pages/Project/tabs/Errors/ErrorsView.tsx | 241 ++++------ .../tabs/FeatureFlags/FeatureFlagsView.tsx | 153 +++--- .../Project/tabs/Funnels/FunnelsView.tsx | 125 +++-- .../pages/Project/tabs/Goals/GoalsView.tsx | 172 ++++--- .../Project/tabs/Sessions/SessionsView.tsx | 231 +++------- web/app/routes/api.analytics.ts | 103 ++++- web/app/routes/projects.$id.tsx | 76 +++ 11 files changed, 1234 insertions(+), 931 deletions(-) diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 5366e3a62..612283333 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -744,6 +744,62 @@ export async function getSessionsServer( return serverFetch(request, `log/sessions?${queryParams.toString()}`, { headers }) } +interface PageflowItem { + type: 'pageview' | 'event' | 'error' + value: string + created: string + metadata?: { key: string; value: string }[] +} + +export interface SessionDetailsResponse { + details: { + psid: string + cc: string | null + os: string | null + br: string | null + dv: string | null + pageviews: number + customEvents: number + errors: number + revenue?: number + refunds?: number + created: string + sdur?: number + profileId: string | null + isIdentified: 1 | 0 + isFirstSession: 1 | 0 + } + chart?: { + x: string[] + pageviews?: number[] + customEvents?: number[] + errors?: number[] + } + pages?: PageflowItem[] + timeBucket?: string +} + +export async function getSessionServer( + request: Request, + pid: string, + psid: string, + timezone: string, + password?: string, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + psid, + timezone, + }) + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/session?${queryParams.toString()}`, { headers }) +} + interface SwetrixError { eid: string name: string @@ -785,3 +841,381 @@ export async function getErrorsServer( return serverFetch(request, `log/errors?${queryParams.toString()}`, { headers }) } + +export interface ErrorDetailsResponse { + details: { + eid: string + name: string + message: string + filename: string + lineno: number + colno: number + count: number + last_seen: string + first_seen: string + status: 'active' | 'regressed' | 'fixed' | 'resolved' + } + chart?: { + x: string[] + occurrences: number[] + affectedUsers: number[] + } + params?: Record + metadata?: { key: string; value: string; count: number }[] + timeBucket?: string +} + +export async function getErrorServer( + request: Request, + pid: string, + eid: string, + params: AnalyticsParams & { options?: Record }, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + eid, + timeBucket: params.timeBucket, + period: params.period, + filters: serializeFiltersForUrl(params.filters), + options: JSON.stringify(params.options || {}), + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/error?${queryParams.toString()}`, { headers }) +} + +export interface ErrorOverviewResponse { + chart?: { + x: string[] + occurrences: number[] + affectedUsers: number[] + } + stats?: { + totalErrors: number + uniqueErrors: number + affectedUsers: number + errorRate?: number + affectedSessions?: number + } + appliedFilters?: AnalyticsFilter[] +} + +export async function getErrorOverviewServer( + request: Request, + pid: string, + params: AnalyticsParams & { options?: Record }, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + timeBucket: params.timeBucket, + period: params.period, + filters: serializeFiltersForUrl(params.filters), + options: JSON.stringify(params.options || {}), + from: params.from || '', + to: params.to || '', + timezone: params.timezone, + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/error-overview?${queryParams.toString()}`, { headers }) +} + +// ============================================================================ +// MARK: Feature Flags API (Deferred Loading) +// ============================================================================ + +export interface TargetingRule { + column: string + filter: string + isExclusive: boolean +} + +export interface ProjectFeatureFlag { + id: string + key: string + description: string | null + flagType: 'boolean' | 'rollout' + rolloutPercentage: number + targetingRules: TargetingRule[] | null + enabled: boolean + pid: string + created: string +} + +export interface FeatureFlagsResponse { + results: ProjectFeatureFlag[] + total: number +} + +export async function getProjectFeatureFlagsServer( + request: Request, + projectId: string, + take = 20, + skip = 0, + search?: string, +): Promise> { + const params = new URLSearchParams({ + take: String(take), + skip: String(skip), + }) + + if (search?.trim()) { + params.append('search', search.trim()) + } + + return serverFetch(request, `feature-flag/project/${projectId}?${params.toString()}`) +} + +export interface FeatureFlagStats { + evaluations: number + profileCount: number + trueCount: number + falseCount: number + truePercentage: number +} + +export async function getFeatureFlagStatsServer( + request: Request, + flagId: string, + period: string, + from = '', + to = '', + timezone?: string, +): Promise> { + const params = new URLSearchParams({ period }) + if (from) params.append('from', from) + if (to) params.append('to', to) + if (timezone) params.append('timezone', timezone) + + return serverFetch(request, `feature-flag/${flagId}/stats?${params.toString()}`) +} + +export interface FeatureFlagProfile { + profileId: string + isIdentified: boolean + lastResult: boolean + evaluationCount: number + lastEvaluated: string +} + +export interface FeatureFlagProfilesResponse { + profiles: FeatureFlagProfile[] + total: number +} + +export async function getFeatureFlagProfilesServer( + request: Request, + flagId: string, + period: string, + from = '', + to = '', + timezone?: string, + take = 15, + skip = 0, + resultFilter?: string, +): Promise> { + const params = new URLSearchParams({ + period, + take: String(take), + skip: String(skip), + }) + if (from) params.append('from', from) + if (to) params.append('to', to) + if (timezone) params.append('timezone', timezone) + if (resultFilter && resultFilter !== 'all') params.append('result', resultFilter) + + return serverFetch(request, `feature-flag/${flagId}/profiles?${params.toString()}`) +} + +// ============================================================================ +// MARK: Goals API (Deferred Loading) +// ============================================================================ + +export interface Goal { + id: string + name: string + type: 'pageview' | 'custom_event' + matchType: 'exact' | 'contains' + value: string | null + metadataFilters: { key: string; value: string }[] | null + active: boolean + pid: string + created: string +} + +export interface GoalsResponse { + results: Goal[] + total: number +} + +export async function getProjectGoalsServer( + request: Request, + projectId: string, + take = 20, + skip = 0, + search?: string, +): Promise> { + const params = new URLSearchParams({ + take: String(take), + skip: String(skip), + }) + + if (search?.trim()) { + params.append('search', search.trim()) + } + + return serverFetch(request, `goal/project/${projectId}?${params.toString()}`) +} + +export interface GoalStats { + conversions: number + uniqueSessions: number + conversionRate: number + previousConversions: number + trend: number +} + +export async function getGoalStatsServer( + request: Request, + goalId: string, + period: string, + from = '', + to = '', + timezone?: string, +): Promise> { + const params = new URLSearchParams({ period }) + if (from) params.append('from', from) + if (to) params.append('to', to) + if (timezone) params.append('timezone', timezone) + + return serverFetch(request, `goal/${goalId}/stats?${params.toString()}`) +} + +export interface GoalChartData { + x: string[] + conversions: number[] + uniqueSessions: number[] +} + +export async function getGoalChartServer( + request: Request, + goalId: string, + period: string, + from = '', + to = '', + timeBucket = 'day', + timezone?: string, +): Promise> { + const params = new URLSearchParams({ period, timeBucket }) + if (from) params.append('from', from) + if (to) params.append('to', to) + if (timezone) params.append('timezone', timezone) + + return serverFetch<{ chart: GoalChartData }>(request, `goal/${goalId}/chart?${params.toString()}`) +} + +// ============================================================================ +// MARK: Alerts API (Deferred Loading) +// ============================================================================ + +type QueryMetric = 'page_views' | 'unique_page_views' | 'online_users' | 'custom_events' | 'errors' +type QueryCondition = 'greater_than' | 'greater_equal_than' | 'less_than' | 'less_equal_than' +type QueryTime = + | 'last_15_minutes' + | 'last_30_minutes' + | 'last_1_hour' + | 'last_4_hours' + | 'last_24_hours' + | 'last_48_hours' + +export interface Alert { + id: string + name: string + active: boolean + queryMetric: QueryMetric + queryCondition: QueryCondition | null + queryValue: number | null + queryTime: QueryTime | null + queryCustomEvent: string | null + lastTriggered: string | null + alertOnNewErrorsOnly: boolean + alertOnEveryCustomEvent: boolean + pid: string + created: string +} + +export interface AlertsResponse { + results: Alert[] + total: number +} + +export async function getProjectAlertsServer( + request: Request, + projectId: string, + take = 25, + skip = 0, +): Promise> { + const params = new URLSearchParams({ + take: String(take), + skip: String(skip), + }) + + return serverFetch(request, `alert/project/${projectId}?${params.toString()}`) +} + +// ============================================================================ +// MARK: Funnels API (Deferred Loading) +// ============================================================================ + +export interface AnalyticsFunnelStep { + value: string + events: number + eventsPerc: number + eventsPercStep: number + dropoff: number + dropoffPerc: number + dropoffPercStep: number +} + +export interface FunnelDataResponse { + funnel: AnalyticsFunnelStep[] + totalPageviews: number +} + +export async function getFunnelDataServer( + request: Request, + pid: string, + funnelId: string, + period: string, + from: string, + to: string, + timezone: string, + password?: string, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + period, + from, + to, + timezone, + funnelId, + }) + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/funnel?${queryParams.toString()}`, { headers }) +} diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 2d0be2f7f..1efca5953 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -5,19 +5,9 @@ import _map from 'lodash/map' import { API_URL } from '~/lib/constants' import { SSOProvider } from '~/lib/models/Auth' -import { - Project, - Overall, - LiveStats, - SwetrixError, - SwetrixErrorDetails, - SessionDetails, - Session, - Profile, - ProfileDetails, -} from '~/lib/models/Project' +import { Project, Overall, LiveStats, Session, Profile, ProfileDetails } from '~/lib/models/Project' import { User } from '~/lib/models/User' -import { Filter, ProjectViewCustomEvent } from '~/pages/Project/View/interfaces/traffic' +import { Filter } from '~/pages/Project/View/interfaces/traffic' import { getAccessToken, setAccessToken } from '~/utils/accessToken' import { clearLocalStorageOnLogout } from '~/utils/auth' import { getRefreshToken } from '~/utils/refreshToken' @@ -106,34 +96,6 @@ export const getProject = (pid: string, password?: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getProjectData = ( - pid: string, - tb = 'hour', - period = '1d', - filters: Filter[] = [], - metrics: ProjectViewCustomEvent[] = [], - from = '', - to = '', - timezone = '', - password: string | undefined = '', - mode: 'periodical' | 'cumulative' = 'periodical', -) => - api - .get( - `log?pid=${pid}&timeBucket=${tb}&period=${period}&metrics=${JSON.stringify(metrics)}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}&mode=${mode}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getCustomEventsMetadata = ( pid: string, event: string, @@ -185,155 +147,6 @@ export const getPropertyMetadata = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getTrafficCompareData = ( - pid: string, - tb = 'hour', - period = '3d', - filters: any[] = [], - from = '', - to = '', - timezone = '', - password: string | undefined = '', - mode: 'periodical' | 'cumulative' = 'periodical', -) => - api - .get( - `log/chart?pid=${pid}&timeBucket=${tb}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}&mode=${mode}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getPerformanceCompareData = ( - pid: string, - tb = 'hour', - period = '3d', - filters: any[] = [], - from = '', - to = '', - timezone = '', - measure = '', - password: string | undefined = '', -) => - api - .get( - `log/performance/chart?pid=${pid}&timeBucket=${tb}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}&measure=${measure}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getPerfData = ( - pid: string, - tb = 'hour', - period = '3d', - filters: any[] = [], - from = '', - to = '', - timezone = '', - measure = '', - password: string | undefined = '', -) => - api - .get( - `log/performance?pid=${pid}&timeBucket=${tb}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}&measure=${measure}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getSessions = ( - pid: string, - period = '3d', - filters: any[] = [], - from = '', - to = '', - take = 30, - skip = 0, - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/sessions?pid=${pid}&take=${take}&skip=${skip}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): { sessions: Session[]; take: number; skip: number; appliedFilters: Filter[] } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getErrors = ( - pid: string, - timeBucket: string, - period = '3d', - filters: any[] = [], - options: any = {}, - from = '', - to = '', - take = 30, - skip = 0, - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/errors?pid=${pid}&timeBucket=${timeBucket}&take=${take}&skip=${skip}&period=${period}&filters=${JSON.stringify( - filters, - )}&options=${JSON.stringify(options)}&from=${from}&to=${to}&timezone=${timezone}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): { errors: SwetrixError[] } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getSession = (pid: string, psid: string, timezone = '', password: string | undefined = '') => - api - .get(`log/session?pid=${pid}&psid=${psid}&timezone=${timezone}`, { - headers: { - 'x-password': password, - }, - }) - .then((response): { details: SessionDetails; [key: string]: any } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProfiles = ( pid: string, period = '3d', @@ -413,90 +226,6 @@ export const getProfileSessions = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getError = ( - pid: string, - eid: string, - timeBucket = 'hour', - period = '7d', - filters: any[] = [], - from = '', - to = '', - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/get-error?pid=${pid}&eid=${eid}&timeBucket=${timeBucket}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): { details: SwetrixErrorDetails; [key: string]: any } => response.data) - .catch((error) => { - throw error.response - }) - -interface ErrorOverviewStats { - totalErrors: number - uniqueErrors: number - affectedSessions: number - affectedUsers: number - errorRate: number -} - -interface MostFrequentError { - eid: string - name: string - message: string - count: number - usersAffected: number - lastSeen: string -} - -interface ErrorOverviewChart { - x: string[] - occurrences: number[] - affectedUsers: number[] -} - -export interface ErrorOverviewResponse { - stats: ErrorOverviewStats - mostFrequentError: MostFrequentError | null - chart: ErrorOverviewChart - timeBucket: string -} - -export const getErrorOverview = ( - pid: string, - timeBucket = 'hour', - period = '7d', - filters: any[] = [], - options: any = {}, - from = '', - to = '', - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/error-overview?pid=${pid}&timeBucket=${timeBucket}&period=${period}&filters=${JSON.stringify( - filters, - )}&options=${JSON.stringify(options)}&from=${from}&to=${to}&timezone=${timezone}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): ErrorOverviewResponse => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) - export interface ErrorAffectedSession { psid: string profileId: string | null @@ -533,26 +262,6 @@ export const getErrorSessions = ( throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message }) -export const getFunnelData = ( - pid: string, - period = '3d', - from = '', - to = '', - timezone = '', - funnelId = '', - password: string | undefined = '', -) => - api - .get(`log/funnel?pid=${pid}&period=${period}&from=${from}&to=${to}&timezone=${timezone}&funnelId=${funnelId}`, { - headers: { - 'x-password': password, - }, - }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getCaptchaData = (pid: string, tb = 'hour', period = '3d', filters: any[] = [], from = '', to = '') => api .get( @@ -592,32 +301,6 @@ export const getOverallStats = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getPerformanceOverallStats = ( - pids: string[], - period: string, - from = '', - to = '', - timezone = 'Etc/GMT', - filters: any = '', - measure: any = '', - password?: string, -) => - api - .get( - `log/performance/birdseye?pids=[${_map(pids, (pid) => `"${pid}"`).join( - ',', - )}]&period=${period}&from=${from}&to=${to}&timezone=${timezone}&filters=${JSON.stringify(filters)}&measure=${measure}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): Overall => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getLiveVisitors = (pids: string[], password?: string): Promise => api .get(`log/hb?pids=[${_map(pids, (pid) => `"${pid}"`).join(',')}]`, { @@ -712,33 +395,6 @@ export const getGoal = (goalId: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getGoalStats = (goalId: string, period: string, from: string = '', to: string = '', timezone?: string) => - api - .get(`/goal/${goalId}/stats`, { - params: { period, from, to, timezone }, - }) - .then((response): GoalStats => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getGoalChart = ( - goalId: string, - period: string, - from: string = '', - to: string = '', - timeBucket: string = 'day', - timezone?: string, -) => - api - .get(`/goal/${goalId}/chart`, { - params: { period, from, to, timeBucket, timezone }, - }) - .then((response): { chart: GoalChartData } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - // Feature Flags API export interface TargetingRule { column: string @@ -798,61 +454,6 @@ export const getProjectFeatureFlags = ( }) } -export const getFeatureFlagStats = ( - flagId: string, - period: string, - from: string = '', - to: string = '', - timezone?: string, -) => - api - .get(`/feature-flag/${flagId}/stats`, { - params: { period, from, to, timezone }, - }) - .then((response): FeatureFlagStats => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export interface FeatureFlagProfile { - profileId: string - isIdentified: boolean - lastResult: boolean - evaluationCount: number - lastEvaluated: string -} - -export const DEFAULT_FEATURE_FLAG_PROFILES_TAKE = 15 - -type FeatureFlagResultFilter = 'all' | 'true' | 'false' - -export const getFeatureFlagProfiles = ( - flagId: string, - period: string, - from: string = '', - to: string = '', - timezone?: string, - take: number = DEFAULT_FEATURE_FLAG_PROFILES_TAKE, - skip: number = 0, - resultFilter: FeatureFlagResultFilter = 'all', -) => - api - .get(`/feature-flag/${flagId}/profiles`, { - params: { - period, - from, - to, - timezone, - take, - skip, - result: resultFilter === 'all' ? undefined : resultFilter, - }, - }) - .then((response): { profiles: FeatureFlagProfile[]; total: number } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - // Experiments (A/B Testing) API export type ExperimentStatus = 'draft' | 'running' | 'paused' | 'completed' diff --git a/web/app/hooks/useAnalyticsProxy.ts b/web/app/hooks/useAnalyticsProxy.ts index 0024d8d81..74320da38 100644 --- a/web/app/hooks/useAnalyticsProxy.ts +++ b/web/app/hooks/useAnalyticsProxy.ts @@ -1,17 +1,26 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useState } from 'react' -import type { SessionsResponse, ErrorsResponse, AnalyticsFilter } from '~/api/api.server' +import type { + SessionsResponse, + ErrorsResponse, + FeatureFlagStats, + FeatureFlagProfilesResponse, + GoalStats, + GoalChartData, + AnalyticsFilter, +} from '~/api/api.server' interface AnalyticsParams { - timeBucket: string + timeBucket?: string period: string from?: string to?: string - timezone: string + timezone?: string filters?: AnalyticsFilter[] take?: number skip?: number options?: Record + resultFilter?: string } interface ProxyResponse { @@ -74,3 +83,123 @@ export function useErrorsProxy() { return { fetchErrors, data, error, isLoading } } + +export function useFeatureFlagStatsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchStats = useCallback(async (flagId: string, params: AnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getFeatureFlagStats', projectId: '', flagId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchStats, data, error, isLoading } +} + +export function useFeatureFlagProfilesProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchProfiles = useCallback(async (flagId: string, params: AnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getFeatureFlagProfiles', projectId: '', flagId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchProfiles, data, error, isLoading } +} + +export function useGoalStatsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchStats = useCallback(async (goalId: string, params: AnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getGoalStats', projectId: '', goalId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchStats, data, error, isLoading } +} + +export function useGoalChartProxy() { + const [data, setData] = useState<{ chart: GoalChartData } | null>(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchChart = useCallback(async (goalId: string, params: AnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getGoalChart', projectId: '', goalId, params }), + }) + const result = (await response.json()) as ProxyResponse<{ chart: GoalChartData }> + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchChart, data, error, isLoading } +} diff --git a/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx b/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx index d052b36e4..5cf40fa10 100644 --- a/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx +++ b/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx @@ -17,12 +17,13 @@ import { FileTextIcon, BellOffIcon, } from 'lucide-react' -import { useMemo, useState, useEffect, useRef } from 'react' +import { useMemo, useState, useEffect, useRef, Suspense, use } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useSearchParams, useFetcher } from 'react-router' +import { Link, useSearchParams, useFetcher, useLoaderData, useRevalidator } from 'react-router' import { toast } from 'sonner' -import type { ProjectViewActionData } from '~/routes/projects.$id' +import type { AlertsResponse } from '~/api/api.server' +import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import { QUERY_METRIC, PLAN_LIMITS, DEFAULT_ALERTS_TAKE } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' import PaidFeature from '~/modals/PaidFeature' @@ -211,8 +212,31 @@ const AlertRow = ({ ) } -const ProjectAlerts = () => { +interface DeferredAlertsData { + alertsData: AlertsResponse | null +} + +function AlertsDataResolver({ children }: { children: (data: DeferredAlertsData) => React.ReactNode }) { + const { alertsData: alertsDataPromise } = useLoaderData() + const alertsData = alertsDataPromise ? use(alertsDataPromise) : null + return <>{children({ alertsData })} +} + +function ProjectAlertsWrapper() { + return ( + }> + {(deferredData) => } + + ) +} + +interface ProjectAlertsInnerProps { + deferredData: DeferredAlertsData +} + +const ProjectAlertsInner = ({ deferredData }: ProjectAlertsInnerProps) => { const { id, project } = useCurrentProject() + const revalidator = useRevalidator() const { t } = useTranslation() const { user, isAuthenticated } = useAuth() const [isPaidFeatureOpened, setIsPaidFeatureOpened] = useState(false) @@ -220,13 +244,16 @@ const ProjectAlerts = () => { const fetcher = useFetcher() const lastHandledData = useRef(null) - const [isLoading, setIsLoading] = useState(null) - const [total, setTotal] = useState(0) - const [alerts, setAlerts] = useState([]) + // Track if we're in pagination mode + const [isSearchMode, setIsSearchMode] = useState(false) + const [total, setTotal] = useState(() => deferredData.alertsData?.total || 0) + const [alerts, setAlerts] = useState(() => (deferredData.alertsData?.results as Alerts[]) || []) const [page, setPage] = useState(1) const [error, setError] = useState(null) + const isLoading = revalidator.state === 'loading' || fetcher.state !== 'idle' + const pageAmount = Math.ceil(total / DEFAULT_ALERTS_TAKE) const limits = PLAN_LIMITS[user?.planCode || 'trial'] @@ -247,9 +274,17 @@ const ProjectAlerts = () => { return newSearchParams.toString() }, [searchParams]) + // Sync state when loader provides new data + useEffect(() => { + if (deferredData.alertsData && revalidator.state === 'idle' && !isSearchMode) { + setAlerts((deferredData.alertsData.results as Alerts[]) || []) + setTotal(deferredData.alertsData.total || 0) + } + }, [revalidator.state, deferredData.alertsData, isSearchMode]) + const loadAlerts = (take: number, skip: number) => { if (fetcher.state !== 'idle') return - setIsLoading(true) + setIsSearchMode(true) fetcher.submit( { intent: 'get-project-alerts', take: String(take), skip: String(skip) }, @@ -270,25 +305,27 @@ const ProjectAlerts = () => { const result = data as { results: Alerts[]; total: number } setAlerts(result.results) setTotal(result.total) - setIsLoading(false) } else if (intent === 'delete-alert') { toast.success(t('alertsSettings.alertDeleted')) - loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) + if (page === 1) { + setIsSearchMode(false) + revalidator.revalidate() + } else { + loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) + } } } else if (fetcherError) { setError(fetcherError) - setIsLoading(false) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fetcher.state, fetcher.data, t, page]) + }, [fetcher.state, fetcher.data, t, page, revalidator]) + // Handle page changes - use fetcher for pagination useEffect(() => { - if (!canManageAlerts) { - return + if (!canManageAlerts) return + if (page > 1 || isSearchMode) { + loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) } - - loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, canManageAlerts]) @@ -342,7 +379,12 @@ const ProjectAlerts = () => { const handleAlertSaved = () => { closeAlertSettings() // Reload alerts after saving - loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) + if (page === 1) { + setIsSearchMode(false) + revalidator.revalidate() + } else { + loadAlerts(DEFAULT_ALERTS_TAKE, (page - 1) * DEFAULT_ALERTS_TAKE) + } } const onDelete = (alertId: string) => { @@ -370,7 +412,7 @@ const ProjectAlerts = () => { ) } - if (error && isLoading === false) { + if (error && !isLoading) { return ( { ) } - if (isLoading || isLoading === null) { - return ( -
- -
- ) - } - // Show alert settings view when editing or creating an alert if (activeAlertId || isCreatingAlert) { return ( @@ -471,4 +505,6 @@ const ProjectAlerts = () => { ) } +const ProjectAlerts = ProjectAlertsWrapper + export default ProjectAlerts diff --git a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx index d2e0d52c6..bb01ee796 100644 --- a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx +++ b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx @@ -18,13 +18,21 @@ import { } from 'lucide-react' import { useState, useEffect, useMemo, useRef, useCallback, lazy, Suspense, use } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useLocation, useSearchParams, useNavigate, useFetcher, useLoaderData, useRevalidator, type LinkProps } from 'react-router' +import { + Link, + useLocation, + useSearchParams, + useNavigate, + useFetcher, + useLoaderData, + useRevalidator, + type LinkProps, +} from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { getErrors, getErrorOverview, getError, type ErrorOverviewResponse } from '~/api' -import type { ErrorsResponse } from '~/api/api.server' -import type { ProjectViewActionData, ProjectLoaderData } from '~/routes/projects.$id' +import type { ErrorsResponse, ErrorDetailsResponse, ErrorOverviewResponse } from '~/api/api.server' +import { useErrorsProxy } from '~/hooks/useAnalyticsProxy' import { TimeFormat, tbsFormatMapper, @@ -55,6 +63,7 @@ import { } from '~/pages/Project/View/ViewProject.helpers' import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' import { useTheme } from '~/providers/ThemeProvider' +import type { ProjectViewActionData, ProjectLoaderData } from '~/routes/projects.$id' import { Badge } from '~/ui/Badge' import BillboardChart from '~/ui/BillboardChart' import Checkbox from '~/ui/Checkbox' @@ -390,14 +399,22 @@ const ERRORS_TAKE = 30 interface DeferredErrorsData { errorsData: ErrorsResponse | null + errorDetails: ErrorDetailsResponse | null + errorOverview: ErrorOverviewResponse | null } function ErrorsDataResolver({ children }: { children: (data: DeferredErrorsData) => React.ReactNode }) { - const { errorsData: errorsDataPromise } = useLoaderData() + const { + errorsData: errorsDataPromise, + errorDetails: errorDetailsPromise, + errorOverview: errorOverviewPromise, + } = useLoaderData() const errorsData = errorsDataPromise ? use(errorsDataPromise) : null + const errorDetails = errorDetailsPromise ? use(errorDetailsPromise) : null + const errorOverview = errorOverviewPromise ? use(errorOverviewPromise) : null - return <>{children({ errorsData })} + return <>{children({ errorsData, errorDetails, errorOverview })} } function ErrorsViewWrapper() { @@ -416,6 +433,7 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { const { id, allowedToManage, project } = useCurrentProject() const projectPassword = useProjectPassword(id) const revalidator = useRevalidator() + const errorsProxy = useErrorsProxy() const { errorsRefreshTrigger, timeBucket, timeFormat, period, dateRange, timezone, filters, size } = useViewProjectContext() const { @@ -442,20 +460,37 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { [ERROR_FILTERS_MAPPING.showResolved]: false, }) - const [overviewLoading, setOverviewLoading] = useState(null) - const [overview, setOverview] = useState(null) + // Overview from loader + const overview = deferredData.errorOverview + const overviewLoading = revalidator.state === 'loading' + const isMountedRef = useRef(true) - const [errorsLoading, setErrorsLoading] = useState(() => deferredData.errorsData ? false : null) + const [errorsLoading, setErrorsLoading] = useState(() => (deferredData.errorsData ? false : null)) const [errors, setErrors] = useState(() => deferredData.errorsData?.errors || []) const [errorsSkip, setErrorsSkip] = useState(() => deferredData.errorsData?.errors?.length || 0) - const [canLoadMoreErrors, setCanLoadMoreErrors] = useState(() => (deferredData.errorsData?.errors?.length || 0) >= ERRORS_TAKE) + const [canLoadMoreErrors, setCanLoadMoreErrors] = useState( + () => (deferredData.errorsData?.errors?.length || 0) >= ERRORS_TAKE, + ) const errorsRequestIdRef = useRef(0) const activeEID = useMemo(() => searchParams.get('eid'), [searchParams]) - const prevActiveEIDRef = useRef(activeEID) - const [activeError, setActiveError] = useState<{ details: SwetrixErrorDetails; [key: string]: any } | null>(null) - const [errorLoading, setErrorLoading] = useState(false) + + // Error details from loader + const activeError = useMemo(() => { + if (deferredData.errorDetails) { + return { + details: deferredData.errorDetails.details as SwetrixErrorDetails, + chart: deferredData.errorDetails.chart, + params: deferredData.errorDetails.params, + metadata: deferredData.errorDetails.metadata, + timeBucket: deferredData.errorDetails.timeBucket, + } + } + return null + }, [deferredData.errorDetails]) + + const errorLoading = activeEID ? revalidator.state === 'loading' : false const errorStatusUpdating = errorStatusFetcher.state !== 'idle' @@ -518,113 +553,35 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { } }, [revalidator.state, deferredData]) - const loadOverview = useCallback(async () => { - if (overviewLoading) return - - setOverviewLoading(true) - try { - const result = await getErrorOverview( - id, - timeBucket, - period, - filters, - errorOptions, - from, - to, - timezone || '', - projectPassword, - ) - if (isMountedRef.current) { - setOverview(result) - } - } catch (reason: any) { - console.error('[ErrorsView] Failed to load overview:', reason) - if (isMountedRef.current) { - toast.error(reason?.message || t('apiNotifications.somethingWentWrong')) - } - } finally { - if (isMountedRef.current) { - setOverviewLoading(false) - } - } - }, [id, timeBucket, period, filters, errorOptions, from, to, timezone, projectPassword, overviewLoading, t]) - - const loadErrors = useCallback( - async (forcedSkip?: number, override?: boolean) => { - if (errorsLoading && !override) return - - const currentRequestId = ++errorsRequestIdRef.current - setErrorsLoading(true) - - const skip = forcedSkip !== undefined ? forcedSkip : errorsSkip - - try { - const { errors: newErrors } = await getErrors( - id, - timeBucket, - period === 'custom' ? '' : period, - filters, - errorOptions, - from, - to, - ERRORS_TAKE, - skip, - timezone || '', - projectPassword, - ) - - if (!isMountedRef.current || currentRequestId !== errorsRequestIdRef.current) return - - if (skip === 0) { - setErrors(newErrors) - } else { - setErrors((prev) => [...prev, ...newErrors]) - } - - setErrorsSkip(skip + ERRORS_TAKE) - setCanLoadMoreErrors(newErrors.length >= ERRORS_TAKE) - } catch (reason: any) { - console.error('[ErrorsView] Failed to load errors:', reason) - if (isMountedRef.current && currentRequestId === errorsRequestIdRef.current) { - toast.error(reason?.message || t('apiNotifications.somethingWentWrong')) - } - } finally { - if (isMountedRef.current && currentRequestId === errorsRequestIdRef.current) { - setErrorsLoading(false) - } - } - }, - [id, timeBucket, period, filters, errorOptions, from, to, timezone, projectPassword, errorsLoading, errorsSkip, t], - ) - - const loadError = useCallback( - async (eid: string) => { - setErrorLoading(true) - - try { - let error - if (period === 'custom' && dateRange) { - error = await getError(id, eid, timeBucket, '', filters, from, to, timezone, projectPassword) - } else { - error = await getError(id, eid, timeBucket, period, filters, '', '', timezone, projectPassword) - } - - setActiveError(error) - } catch (reason: any) { - if (reason?.status === 400) { - setErrorLoading(false) - setActiveError(null) - return - } + // Load more errors via proxy + const loadMoreErrors = useCallback(() => { + if (errorsLoading) return + + errorsProxy.fetchErrors(id, { + timeBucket, + period: period === 'custom' ? '' : period, + from: from || undefined, + to: to || undefined, + timezone: timezone || '', + filters, + take: ERRORS_TAKE, + skip: errorsSkip, + options: errorOptions, + }) + }, [id, timeBucket, period, from, to, timezone, filters, errorsSkip, errorOptions, errorsLoading, errorsProxy]) - const message = _isEmpty(reason.data?.message) ? reason.data : reason.data.message - console.error('[ErrorsView] Failed to load error:', message) - toast.error(message) - } - setErrorLoading(false) - }, - [dateRange, id, period, timeBucket, projectPassword, timezone, filters, from, to], - ) + // Handle proxy response for pagination + useEffect(() => { + if (errorsProxy.data && !errorsProxy.isLoading) { + const newErrors = errorsProxy.data.errors || [] + setErrors((prev) => [...prev, ...newErrors]) + setErrorsSkip((prev) => prev + ERRORS_TAKE) + setCanLoadMoreErrors(newErrors.length >= ERRORS_TAKE) + } + if (errorsProxy.error) { + toast.error(errorsProxy.error) + } + }, [errorsProxy.data, errorsProxy.error, errorsProxy.isLoading]) const updateStatusInErrors = useCallback( (status: 'active' | 'resolved') => { @@ -654,7 +611,7 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { if (success && pendingStatusUpdate.current) { updateStatusInErrors(pendingStatusUpdate.current) if (activeEID) { - loadError(activeEID) + revalidator.revalidate() } toast.success(t('apiNotifications.errorStatusUpdated')) pendingStatusUpdate.current = null @@ -705,46 +662,11 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { ] }, [t, errorOptions]) - // Load data on mount and when params change (only for list view) - useEffect(() => { - if (!id || !timeBucket || !period || activeEID) return - - loadOverview() - setErrorsSkip(0) - loadErrors(0, true) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, period, from, to, timeBucket, timezone, filters, errorOptions]) - - // Handle activeEID changes - useEffect(() => { - if (!activeEID) { - setActiveError(null) - // Coming back from error detail to list: reset and reload - if (prevActiveEIDRef.current) { - setErrorsSkip(0) - loadErrors(0, true) - loadOverview() - } - prevActiveEIDRef.current = null - return - } - - loadError(activeEID) - prevActiveEIDRef.current = activeEID - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [period, dateRange, timeBucket, activeEID, filters]) - - // Handle refresh trigger (list + detail) + // Handle refresh trigger - use revalidator for URL-based data useEffect(() => { - if (errorsRefreshTrigger <= 0) return - - if (activeEID) { - loadError(activeEID) - return + if (errorsRefreshTrigger > 0) { + revalidator.revalidate() } - - revalidator.revalidate() - loadOverview() // eslint-disable-next-line react-hooks/exhaustive-deps }, [errorsRefreshTrigger]) @@ -966,7 +888,6 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { ? () => { const countryData = activeError?.params?.cc || [] const regionData = activeError?.params?.rg || [] - // @ts-expect-error const total = countryData.reduce((acc, curr) => acc + curr.count, 0) return ( @@ -1205,7 +1126,7 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => {
- ) - } - return ( <> @@ -855,4 +894,6 @@ const FeatureFlagsView = ({ period, from = '', to = '', timezone }: FeatureFlags ) } +const FeatureFlagsView = FeatureFlagsViewWrapper + export default FeatureFlagsView diff --git a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx index 599e6380d..8b63ea075 100644 --- a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx +++ b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx @@ -4,13 +4,13 @@ import _find from 'lodash/find' import _includes from 'lodash/includes' import _isEmpty from 'lodash/isEmpty' import { FilterIcon } from 'lucide-react' -import { useState, useEffect, useMemo, useRef } from 'react' +import { useState, useEffect, useMemo, useRef, Suspense, use } from 'react' import { useTranslation } from 'react-i18next' -import { Link, useSearchParams, useFetcher } from 'react-router' +import { Link, useSearchParams, useFetcher, useLoaderData, useRevalidator } from 'react-router' import { toast } from 'sonner' -import { getFunnelData } from '~/api' -import type { ProjectViewActionData } from '~/routes/projects.$id' +import type { FunnelDataResponse } from '~/api/api.server' +import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import { FUNNELS_PERIOD_PAIRS } from '~/lib/constants' import { Funnel, AnalyticsFunnel } from '~/lib/models/Project' import NewFunnel from '~/modals/NewFunnel' @@ -27,9 +27,32 @@ import { Text } from '~/ui/Text' import { nLocaleFormatter } from '~/utils/generic' import routes from '~/utils/routes' -const FunnelsView = () => { +interface DeferredFunnelData { + funnelData: FunnelDataResponse | null +} + +function FunnelDataResolver({ children }: { children: (data: DeferredFunnelData) => React.ReactNode }) { + const { funnelData: funnelDataPromise } = useLoaderData() + const funnelData = funnelDataPromise ? use(funnelDataPromise) : null + return <>{children({ funnelData })} +} + +function FunnelsViewWrapper() { + return ( + }> + {(deferredData) => } + + ) +} + +interface FunnelsViewInnerProps { + deferredData: DeferredFunnelData +} + +const FunnelsViewInner = ({ deferredData }: FunnelsViewInnerProps) => { const { id, project, mergeProject, allowedToManage } = useCurrentProject() const projectPassword = useProjectPassword(id) + const revalidator = useRevalidator() const { isAuthenticated } = useAuth() const { timezone, period, dateRange, periodPairs, funnelsRefreshTrigger } = useViewProjectContext() @@ -51,13 +74,23 @@ const FunnelsView = () => { const [isNewFunnelOpened, setIsNewFunnelOpened] = useState(false) const [funnelToEdit, setFunnelToEdit] = useState(undefined) const [dataLoading, setDataLoading] = useState(false) - const [analyticsLoading, setAnalyticsLoading] = useState(true) + + // Initialize funnelAnalytics from loader data const [funnelAnalytics, setFunnelAnalytics] = useState<{ funnel: AnalyticsFunnel[] totalPageviews: number - } | null>(null) + } | null>(() => { + if (deferredData.funnelData) { + return { + funnel: deferredData.funnelData.funnel as AnalyticsFunnel[], + totalPageviews: deferredData.funnelData.totalPageviews, + } + } + return null + }) const funnelActionLoading = fetcher.state !== 'idle' + const analyticsLoading = revalidator.state === 'loading' // Search params without the funnel id. Needed for the back button. const pureSearchParams = useMemo(() => { @@ -153,43 +186,25 @@ const FunnelsView = () => { fetcher.submit({ intent: 'delete-funnel', funnelId }, { method: 'POST', action: `/projects/${id}` }) } - const loadFunnelsData = async () => { + // Sync funnelAnalytics when loader data changes + useEffect(() => { + if (deferredData.funnelData && revalidator.state === 'idle') { + setFunnelAnalytics({ + funnel: deferredData.funnelData.funnel as AnalyticsFunnel[], + totalPageviews: deferredData.funnelData.totalPageviews, + }) + setDataLoading(false) + } + }, [deferredData.funnelData, revalidator.state]) + + const loadFunnelsData = () => { if (!activeFunnel?.id) { return } setDataLoading(true) - - try { - let dataFunnel: { funnel: AnalyticsFunnel[]; totalPageviews: number } - let from - let to - - if (dateRange) { - from = getFormatDate(dateRange[0]) - to = getFormatDate(dateRange[1]) - } - - if (period === 'custom' && dateRange) { - dataFunnel = await getFunnelData(id, '', from, to, timezone, activeFunnel.id, projectPassword) - } else { - dataFunnel = await getFunnelData(id, period, '', '', timezone, activeFunnel.id, projectPassword) - } - - const { funnel, totalPageviews } = dataFunnel - - if (isMountedRef.current) { - setFunnelAnalytics({ funnel, totalPageviews }) - setAnalyticsLoading(false) - setDataLoading(false) - } - } catch (reason) { - if (isMountedRef.current) { - setAnalyticsLoading(false) - setDataLoading(false) - console.error('[ERROR](loadFunnelsData) Loading funnels data failed:', reason) - } - } + // Use revalidator to trigger a new loader fetch with updated URL params + revalidator.revalidate() } const funnelSummary = useMemo(() => { @@ -210,24 +225,38 @@ const FunnelsView = () => { } }, [funnelAnalytics]) - // Load funnels data when activeFunnel changes + // When activeFunnel changes (URL params) and we have loader data, sync it + // The actual data fetching happens via the loader when URL changes useEffect(() => { - if (!project) return - - loadFunnelsData() + if (!project || !activeFunnel) return + + // If we have loader data for this funnel, use it + if (deferredData.funnelData) { + setFunnelAnalytics({ + funnel: deferredData.funnelData.funnel as AnalyticsFunnel[], + totalPageviews: deferredData.funnelData.totalPageviews, + }) + } else { + // No loader data yet, trigger revalidation + setDataLoading(true) + revalidator.revalidate() + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeFunnel, project, dateRange, period]) + }, [activeFunnel?.id]) // Handle refresh trigger from parent useEffect(() => { if (funnelsRefreshTrigger > 0) { - loadFunnelsData() + if (activeFunnel?.id) { + setDataLoading(true) + } + revalidator.revalidate() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [funnelsRefreshTrigger]) - // Show loader during initial load when viewing a specific funnel - if (activeFunnel && analyticsLoading) { + // Show loader during initial load when viewing a specific funnel (only when no data yet) + if (activeFunnel && analyticsLoading && !funnelAnalytics) { return (
{ ) } +const FunnelsView = FunnelsViewWrapper + export default FunnelsView diff --git a/web/app/pages/Project/tabs/Goals/GoalsView.tsx b/web/app/pages/Project/tabs/Goals/GoalsView.tsx index bcac9b782..cfcde99dd 100644 --- a/web/app/pages/Project/tabs/Goals/GoalsView.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalsView.tsx @@ -17,21 +17,14 @@ import { MousePointerClickIcon, ChevronDownIcon, } from 'lucide-react' -import { useState, useEffect, useMemo, useRef, useCallback } from 'react' +import { useState, useEffect, useMemo, useRef, useCallback, Suspense, use } from 'react' import { useTranslation } from 'react-i18next' -import { useSearchParams, useFetcher } from 'react-router' +import { useSearchParams, useFetcher, useLoaderData, useRevalidator } from 'react-router' import { toast } from 'sonner' -import { - getProjectGoals, - getGoalStats, - getGoalChart, - DEFAULT_GOALS_TAKE, - type Goal, - type GoalStats, - type GoalChartData, -} from '~/api' -import type { ProjectViewActionData } from '~/routes/projects.$id' +import type { GoalsResponse, Goal, GoalStats, GoalChartData } from '~/api/api.server' +import { useGoalStatsProxy, useGoalChartProxy } from '~/hooks/useAnalyticsProxy' +import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import { TimeFormat, tbsFormatMapper, @@ -58,6 +51,8 @@ import routes from '~/utils/routes' import GoalSettingsModal from './GoalSettingsModal' +const DEFAULT_GOALS_TAKE = 20 + // Calculate optimal Y axis ticks const calculateOptimalTicks = (data: number[], targetCount: number = 6): number[] => { const validData = data.filter((n) => n !== undefined && n !== null && Number.isFinite(n)) @@ -427,19 +422,42 @@ interface GoalsViewProps { timezone?: string } -const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => { +interface DeferredGoalsData { + goalsData: GoalsResponse | null +} + +function GoalsDataResolver({ children }: { children: (data: DeferredGoalsData) => React.ReactNode }) { + const { goalsData: goalsDataPromise } = useLoaderData() + const goalsData = goalsDataPromise ? use(goalsDataPromise) : null + return <>{children({ goalsData })} +} + +function GoalsViewWrapper(props: GoalsViewProps) { + return ( + }> + {(deferredData) => } + + ) +} + +interface GoalsViewInnerProps extends GoalsViewProps { + deferredData: DeferredGoalsData +} + +const GoalsViewInner = ({ period, from = '', to = '', timezone, deferredData }: GoalsViewInnerProps) => { const { id } = useCurrentProject() + const revalidator = useRevalidator() const { goalsRefreshTrigger, timeBucket, timeFormat } = useViewProjectContext() const [searchParams] = useSearchParams() const isEmbedded = searchParams.get('embedded') === 'true' const { t } = useTranslation() const fetcher = useFetcher() - const [isLoading, setIsLoading] = useState(null) - const isLoadingRef = useRef(false) // Use ref to avoid stale closure issues in loadGoals - const isMountedRef = useRef(true) // Track if component is mounted to prevent memory leaks - const [total, setTotal] = useState(0) - const [goals, setGoals] = useState([]) + // Track if we're in search/pagination mode + const [isSearchMode, setIsSearchMode] = useState(false) + const isMountedRef = useRef(true) + const [total, setTotal] = useState(() => deferredData.goalsData?.total || 0) + const [goals, setGoals] = useState(() => deferredData.goalsData?.results || []) const [goalStats, setGoalStats] = useState>({}) const [statsLoading, setStatsLoading] = useState>({}) const [page, setPage] = useState(1) @@ -456,6 +474,8 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => const [isModalOpen, setIsModalOpen] = useState(false) const [editingGoalId, setEditingGoalId] = useState(null) + const isLoading = revalidator.state === 'loading' || fetcher.state !== 'idle' + // Cleanup on unmount useEffect(() => { isMountedRef.current = true @@ -464,6 +484,14 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => } }, []) + // Sync state when loader provides new data + useEffect(() => { + if (deferredData.goalsData && revalidator.state === 'idle' && !isSearchMode) { + setGoals(deferredData.goalsData.results || []) + setTotal(deferredData.goalsData.total || 0) + } + }, [revalidator.state, deferredData.goalsData, isSearchMode]) + const pageAmount = Math.ceil(total / DEFAULT_GOALS_TAKE) // Debounced search handler @@ -484,35 +512,47 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => } }, [filterQuery, debouncedSetSearch]) - const loadGoals = async (take: number, skip: number, search?: string) => { - if (isLoadingRef.current) { - return - } - isLoadingRef.current = true - setIsLoading(true) + const loadGoals = useCallback( + (take: number, skip: number, search?: string) => { + if (fetcher.state !== 'idle') return + setIsSearchMode(true) + + fetcher.submit( + { + intent: 'get-project-goals', + take: String(take), + skip: String(skip), + search: search || '', + }, + { method: 'POST' }, + ) + }, + [fetcher], + ) - try { - const result = await getProjectGoals(id, take, skip, search) - if (isMountedRef.current) { - setGoals(result.results) - setTotal(result.total) - } - } catch (reason: any) { - if (isMountedRef.current) { - setError(reason?.message || reason?.toString() || 'Unknown error') - } - } finally { - isLoadingRef.current = false + // Handle fetcher response for goals list + useEffect(() => { + if (fetcher.data?.intent === 'get-project-goals' && fetcher.state === 'idle') { if (isMountedRef.current) { - setIsLoading(false) + if (fetcher.data.success && fetcher.data.data) { + const result = fetcher.data.data as { results: Goal[]; total: number } + setGoals(result.results) + setTotal(result.total) + } else if (fetcher.data.error) { + setError(fetcher.data.error) + } } } - } + }, [fetcher.data, fetcher.state]) + + // Proxy hooks for stats and chart + const statsProxy = useGoalStatsProxy() + const chartProxy = useGoalChartProxy() const loadGoalStats = async (goalId: string) => { setStatsLoading((prev) => ({ ...prev, [goalId]: true })) try { - const stats = await getGoalStats(goalId, period, from, to, timezone) + const stats = await statsProxy.fetchStats(goalId, { period, from, to, timezone }) if (isMountedRef.current) { setGoalStats((prev) => ({ ...prev, [goalId]: stats })) } @@ -531,8 +571,8 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => const loadGoalChartData = async (goalId: string, showLoading = true) => { setChartLoading((prev) => ({ ...prev, [goalId]: showLoading ? true : prev[goalId] })) try { - const result = await getGoalChart(goalId, period, from, to, timeBucket, timezone) - if (isMountedRef.current) { + const result = await chartProxy.fetchChart(goalId, { period, from, to, timeBucket, timezone }) + if (isMountedRef.current && result) { setGoalChartData((prev) => ({ ...prev, [goalId]: result.chart })) } } catch (err) { @@ -541,7 +581,6 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => setGoalChartData((prev) => ({ ...prev, [goalId]: null })) } } finally { - // Always clear loading state if (isMountedRef.current) { setChartLoading((prev) => ({ ...prev, [goalId]: false })) } @@ -561,8 +600,11 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => } } + // Handle page/search changes - use fetcher for pagination useEffect(() => { - loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) + if (page > 1 || debouncedSearch || isSearchMode) { + loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, debouncedSearch]) @@ -587,15 +629,17 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => // Refresh goals data when refresh button is clicked useEffect(() => { if (goalsRefreshTrigger > 0) { - loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) + if (page === 1 && !debouncedSearch) { + setIsSearchMode(false) + revalidator.revalidate() + } else { + loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) + } // Clear cached chart data for non-expanded goals, silently refresh expanded goal if (expandedGoalId) { - // Keep only the expanded goal's data while we fetch fresh data setGoalChartData((prev) => ({ [expandedGoalId]: prev[expandedGoalId] })) - // Silently fetch new data without showing loading indicator loadGoalChartData(expandedGoalId, false) } else { - // No expanded goal, just clear all cached data setGoalChartData({}) } } @@ -618,18 +662,28 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => } const handleModalSuccess = () => { - loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) + if (page === 1 && !debouncedSearch) { + setIsSearchMode(false) + revalidator.revalidate() + } else { + loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) + } } // Handle fetcher responses for delete useEffect(() => { if (fetcher.data?.success && fetcher.data?.intent === 'delete-goal') { toast.success(t('goals.deleted')) - loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) + if (page === 1 && !debouncedSearch) { + setIsSearchMode(false) + revalidator.revalidate() + } else { + loadGoals(DEFAULT_GOALS_TAKE, (page - 1) * DEFAULT_GOALS_TAKE, debouncedSearch || undefined) + } } else if (fetcher.data?.error && fetcher.data?.intent === 'delete-goal') { toast.error(fetcher.data.error) } - }, [fetcher.data, t, page, debouncedSearch]) // eslint-disable-line react-hooks/exhaustive-deps + }, [fetcher.data, t, page, debouncedSearch, revalidator]) // eslint-disable-line react-hooks/exhaustive-deps const handleDeleteGoal = (goalId: string) => { const formData = new FormData() @@ -638,7 +692,7 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => fetcher.submit(formData, { method: 'post' }) } - if (error && isLoading === false) { + if (error && !isLoading) { return ( ) } - // Show Loader only on initial load (no existing data) - if ((isLoading || isLoading === null) && _isEmpty(goals)) { - return ( -
- -
- ) - } - return ( <> @@ -760,4 +800,6 @@ const GoalsView = ({ period, from = '', to = '', timezone }: GoalsViewProps) => ) } +const GoalsView = GoalsViewWrapper + export default GoalsView diff --git a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx index 08e130387..4868db842 100644 --- a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx +++ b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx @@ -5,8 +5,8 @@ import { useState, useEffect, useMemo, useRef, Suspense, use } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams, useLoaderData, useRevalidator } from 'react-router' -import { getSessions, getSession } from '~/api' -import type { SessionsResponse } from '~/api/api.server' +import type { SessionsResponse, SessionDetailsResponse } from '~/api/api.server' +import { useSessionsProxy } from '~/hooks/useAnalyticsProxy' import type { ProjectLoaderData } from '~/routes/projects.$id' import { Session, SessionDetails as SessionDetailsType } from '~/lib/models/Project' import NoSessions from '~/pages/Project/tabs/Sessions/components/NoSessions' @@ -16,7 +16,7 @@ import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import Filters from '~/pages/Project/View/components/Filters' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' import { getFormatDate } from '~/pages/Project/View/ViewProject.helpers' -import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import { useCurrentProject } from '~/providers/CurrentProjectProvider' import Spin from '~/ui/icons/Spin' import Loader from '~/ui/Loader' import LoadingBar from '~/ui/LoadingBar' @@ -51,14 +51,17 @@ interface SessionsViewProps { interface DeferredSessionsData { sessionsData: SessionsResponse | null + sessionDetails: SessionDetailsResponse | null } function SessionsDataResolver({ children }: { children: (data: DeferredSessionsData) => React.ReactNode }) { - const { sessionsData: sessionsDataPromise } = useLoaderData() + const { sessionsData: sessionsDataPromise, sessionDetails: sessionDetailsPromise } = + useLoaderData() const sessionsData = sessionsDataPromise ? use(sessionsDataPromise) : null + const sessionDetails = sessionDetailsPromise ? use(sessionDetailsPromise) : null - return <>{children({ sessionsData })} + return <>{children({ sessionsData, sessionDetails })} } function SessionsViewWrapper(props: SessionsViewProps) { @@ -77,31 +80,40 @@ interface SessionsViewInnerProps extends SessionsViewProps { const SessionsViewInner = ({ tnMapping, rotateXAxis, deferredData }: SessionsViewInnerProps) => { const { id, project } = useCurrentProject() - const projectPassword = useProjectPassword(id) const revalidator = useRevalidator() const { sessionsRefreshTrigger, timezone, period, dateRange, filters, timeFormat } = useViewProjectContext() const { t } = useTranslation('common') const [searchParams] = useSearchParams() - // Initialize state from deferred data - const initialDataProcessed = useRef(false) + // Proxy for pagination + const sessionsProxy = useSessionsProxy() - // Session list state + // Session list state - derived from loader const [sessions, setSessions] = useState(() => deferredData.sessionsData?.sessions || []) - const [sessionsLoading, setSessionsLoading] = useState(() => deferredData.sessionsData ? false : null) - const [sessionsSkip, setSessionsSkip] = useState(0) - const [canLoadMoreSessions, setCanLoadMoreSessions] = useState(false) - const sessionsRequestIdRef = useRef(0) + const [sessionsSkip, setSessionsSkip] = useState(SESSIONS_TAKE) + const [canLoadMoreSessions, setCanLoadMoreSessions] = useState( + () => (deferredData.sessionsData?.sessions?.length || 0) >= SESSIONS_TAKE, + ) - // Session detail state - const [activeSession, setActiveSession] = useState(null) - const [sessionLoading, setSessionLoading] = useState(false) + // Session detail - derived from loader when available + const activeSession: ActiveSession | null = useMemo(() => { + if (deferredData.sessionDetails) { + return { + details: deferredData.sessionDetails.details as SessionDetailsType, + chart: deferredData.sessionDetails.chart, + pages: deferredData.sessionDetails.pages, + timeBucket: deferredData.sessionDetails.timeBucket, + } + } + return null + }, [deferredData.sessionDetails]) + + const sessionsLoading = revalidator.state === 'loading' || sessionsProxy.isLoading const [error, setError] = useState(null) const isMountedRef = useRef(true) const prevActivePSIDRef = useRef(null) - const skipNextSessionsAutoLoadRef = useRef(false) const activePSID = useMemo(() => { return searchParams.get('psid') @@ -131,177 +143,64 @@ const SessionsViewInner = ({ tnMapping, rotateXAxis, deferredData }: SessionsVie } }, []) - // Process deferred data on mount + // Sync sessions state when loader provides new data useEffect(() => { - if (initialDataProcessed.current) return - initialDataProcessed.current = true - - if (deferredData.sessionsData) { + if (deferredData.sessionsData && revalidator.state === 'idle') { const sessionsList = deferredData.sessionsData.sessions || [] setSessions(sessionsList) setSessionsSkip(SESSIONS_TAKE) setCanLoadMoreSessions(sessionsList.length >= SESSIONS_TAKE) - } else { - setSessions([]) - setCanLoadMoreSessions(false) } - setSessionsLoading(false) - }, [deferredData]) + }, [revalidator.state, deferredData.sessionsData]) - // Sync state when revalidation completes with new data + // Handle proxy response for pagination useEffect(() => { - if (!initialDataProcessed.current) return - if (revalidator.state === 'idle') { - if (deferredData.sessionsData) { - const sessionsList = deferredData.sessionsData.sessions || [] - setSessions(sessionsList) - setSessionsSkip(SESSIONS_TAKE) - setCanLoadMoreSessions(sessionsList.length >= SESSIONS_TAKE) - } else { - setSessions([]) - setCanLoadMoreSessions(false) - } - setSessionsLoading(false) - } else if (revalidator.state === 'loading') { - setSessionsLoading(true) + if (sessionsProxy.data && !sessionsProxy.isLoading) { + const newSessions = sessionsProxy.data.sessions || [] + setSessions((prev) => [...prev, ...newSessions]) + setSessionsSkip((prev) => prev + SESSIONS_TAKE) + setCanLoadMoreSessions(newSessions.length >= SESSIONS_TAKE) } - }, [revalidator.state, deferredData]) - - // Reset sessions when filters change - useEffect(() => { - sessionsRequestIdRef.current += 1 - setSessionsSkip(0) - setSessions([]) - setSessionsLoading(null) - }, [filters]) - - const loadSessions = async (forcedSkip?: number, override?: boolean) => { - if (sessionsLoading) { - return + if (sessionsProxy.error) { + setError(sessionsProxy.error) } + }, [sessionsProxy.data, sessionsProxy.error, sessionsProxy.isLoading]) - const requestId = sessionsRequestIdRef.current - setSessionsLoading(true) + // Load more sessions via proxy + const loadMoreSessions = () => { + if (sessionsLoading) return - try { - const skip = typeof forcedSkip === 'number' ? forcedSkip : sessionsSkip - let dataSessions: { sessions: Session[] } - let from - let to - - if (dateRange) { - from = getFormatDate(dateRange[0]) - to = getFormatDate(dateRange[1]) - } + let from: string | undefined + let to: string | undefined - if (period === 'custom' && dateRange) { - dataSessions = await getSessions(id, '', filters, from, to, SESSIONS_TAKE, skip, timezone, projectPassword) - } else { - dataSessions = await getSessions(id, period, filters, '', '', SESSIONS_TAKE, skip, timezone, projectPassword) - } - - if (requestId === sessionsRequestIdRef.current && isMountedRef.current) { - if (override) { - setSessions(dataSessions?.sessions || []) - } else { - setSessions((prev) => [...prev, ...(dataSessions?.sessions || [])]) - } - setSessionsSkip((prev) => { - if (typeof forcedSkip === 'number') { - return SESSIONS_TAKE + forcedSkip - } - - return SESSIONS_TAKE + prev - }) - - if (dataSessions?.sessions?.length < SESSIONS_TAKE) { - setCanLoadMoreSessions(false) - } else { - setCanLoadMoreSessions(true) - } - } - } catch (reason: any) { - console.error('[ERROR](loadSessions) Loading sessions data failed:', reason) - if (isMountedRef.current) { - setError(reason?.message || reason?.toString() || 'Unknown error') - } - } finally { - if (requestId === sessionsRequestIdRef.current && isMountedRef.current) { - setSessionsLoading(false) - } + if (dateRange) { + from = getFormatDate(dateRange[0]) + to = getFormatDate(dateRange[1]) } - } - - const loadSession = async (psid: string) => { - if (sessionLoading) { - return - } - - setSessionLoading(true) - try { - const session = await getSession(id, psid, timezone, projectPassword) - if (isMountedRef.current) { - setActiveSession(session) - } - } catch (reason: any) { - console.error('[ERROR] (loadSession)(getSession)', reason) - if (isMountedRef.current) { - setError(reason?.message || reason?.toString() || 'Unknown error') - } - } finally { - if (isMountedRef.current) { - setSessionLoading(false) - } - } + sessionsProxy.fetchSessions(id, { + timeBucket: 'day', + period: period === 'custom' ? '' : period, + from, + to, + timezone, + filters, + take: SESSIONS_TAKE, + skip: sessionsSkip, + }) } - // Handle session detail loading - useEffect(() => { - if (!activePSID) { - setActiveSession(null) - // Coming back from a session detail to the list: reset pagination and reload first page - if (prevActivePSIDRef.current) { - skipNextSessionsAutoLoadRef.current = true - setSessionsSkip(0) - loadSessions(0, true) - } - prevActivePSIDRef.current = null - return - } - - loadSession(activePSID) - prevActivePSIDRef.current = activePSID - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [period, dateRange, activePSID]) - - // Load sessions list - useEffect(() => { - if (!project || activePSID) { - return - } - - if (skipNextSessionsAutoLoadRef.current) { - skipNextSessionsAutoLoadRef.current = false - return - } - - loadSessions() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dateRange, filters, id, period, timezone, project, activePSID]) - - // Handle refresh trigger + // Handle refresh trigger - use revalidator for URL-based data useEffect(() => { if (sessionsRefreshTrigger > 0) { - if (activePSID) { - loadSession(activePSID) - } else { - revalidator.revalidate() - } + revalidator.revalidate() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionsRefreshTrigger]) + // Session detail loading state + const sessionLoading = activePSID ? revalidator.state === 'loading' : false + if (error && sessionsLoading === false) { return ( loadSessions()} + onClick={loadMoreSessions} className={cx( 'relative mx-auto mt-2 flex items-center rounded-md border border-transparent p-2 text-sm font-medium text-gray-700 ring-inset hover:border-gray-300 hover:bg-white focus:z-10 focus:ring-1 focus:ring-indigo-500 focus:outline-hidden dark:bg-slate-900 dark:text-gray-50 hover:dark:border-slate-700/80 dark:hover:bg-slate-800 focus:dark:ring-gray-200', { diff --git a/web/app/routes/api.analytics.ts b/web/app/routes/api.analytics.ts index 79393e2f4..51d19f89b 100644 --- a/web/app/routes/api.analytics.ts +++ b/web/app/routes/api.analytics.ts @@ -3,25 +3,42 @@ import { type ActionFunctionArgs, type LoaderFunctionArgs, data } from 'react-ro import { getSessionsServer, getErrorsServer, + getFeatureFlagStatsServer, + getFeatureFlagProfilesServer, + getGoalStatsServer, + getGoalChartServer, type AnalyticsParams, type SessionsResponse, type ErrorsResponse, + type FeatureFlagStats, + type FeatureFlagProfilesResponse, + type GoalStats, + type GoalChartData, } from '~/api/api.server' import { getProjectPasswordCookie } from '~/utils/session.server' interface ProxyRequest { - action: 'getSessions' | 'getErrors' + action: + | 'getSessions' + | 'getErrors' + | 'getFeatureFlagStats' + | 'getFeatureFlagProfiles' + | 'getGoalStats' + | 'getGoalChart' projectId: string + flagId?: string + goalId?: string params: { - timeBucket: string + timeBucket?: string period: string from?: string to?: string - timezone: string + timezone?: string filters?: AnalyticsParams['filters'] take?: number skip?: number options?: Record + resultFilter?: string } } @@ -38,11 +55,11 @@ export async function action({ request }: ActionFunctionArgs) { const password = getProjectPasswordCookie(request, projectId) const analyticsParams: AnalyticsParams & { take?: number; skip?: number; options?: Record } = { - timeBucket: params.timeBucket, + timeBucket: params.timeBucket || 'day', period: params.period, from: params.from, to: params.to, - timezone: params.timezone, + timezone: params.timezone || 'UTC', filters: params.filters || [], password: password || undefined, take: params.take, @@ -68,6 +85,82 @@ export async function action({ request }: ActionFunctionArgs) { }) } + case 'getFeatureFlagStats': { + if (!body.flagId) { + return data>({ data: null, error: 'flagId is required' }, { status: 400 }) + } + const result = await getFeatureFlagStatsServer( + request, + body.flagId, + params.period, + params.from || '', + params.to || '', + params.timezone, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getFeatureFlagProfiles': { + if (!body.flagId) { + return data>({ data: null, error: 'flagId is required' }, { status: 400 }) + } + const result = await getFeatureFlagProfilesServer( + request, + body.flagId, + params.period, + params.from || '', + params.to || '', + params.timezone, + params.take || 15, + params.skip || 0, + params.resultFilter, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getGoalStats': { + if (!body.goalId) { + return data>({ data: null, error: 'goalId is required' }, { status: 400 }) + } + const result = await getGoalStatsServer( + request, + body.goalId, + params.period, + params.from || '', + params.to || '', + params.timezone, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getGoalChart': { + if (!body.goalId) { + return data>({ data: null, error: 'goalId is required' }, { status: 400 }) + } + const result = await getGoalChartServer( + request, + body.goalId, + params.period, + params.from || '', + params.to || '', + params.timeBucket || 'day', + params.timezone, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + default: return data>({ data: null, error: `Unknown action: ${action}` }, { status: 400 }) } diff --git a/web/app/routes/projects.$id.tsx b/web/app/routes/projects.$id.tsx index 62a491880..c20cd7428 100644 --- a/web/app/routes/projects.$id.tsx +++ b/web/app/routes/projects.$id.tsx @@ -13,13 +13,27 @@ import { getPerfDataServer, getPerformanceOverallStatsServer, getSessionsServer, + getSessionServer, getErrorsServer, + getErrorServer, + getErrorOverviewServer, + getProjectFeatureFlagsServer, + getProjectGoalsServer, + getProjectAlertsServer, + getFunnelDataServer, type TrafficLogResponse, type OverallObject, type PerformanceDataResponse, type PerformanceOverallObject, type SessionsResponse, + type SessionDetailsResponse, type ErrorsResponse, + type ErrorDetailsResponse, + type ErrorOverviewResponse, + type FeatureFlagsResponse, + type GoalsResponse, + type AlertsResponse, + type FunnelDataResponse, type AnalyticsFilter, } from '~/api/api.server' import { useRequiredParams } from '~/hooks/useRequiredParams' @@ -149,8 +163,19 @@ export interface ProjectLoaderData { perfOverallStats?: Promise | null> // Sessions data sessionsData?: Promise + sessionDetails?: Promise // Errors data errorsData?: Promise + errorDetails?: Promise + errorOverview?: Promise + // Feature Flags data + featureFlagsData?: Promise + // Goals data + goalsData?: Promise + // Alerts data + alertsData?: Promise + // Funnels data + funnelData?: Promise } export async function loader({ request, params }: LoaderFunctionArgs) { @@ -234,7 +259,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) { let perfData: Promise | undefined let perfOverallStats: Promise | null> | undefined let sessionsData: Promise | undefined + let sessionDetails: Promise | undefined let errorsData: Promise | undefined + let errorDetails: Promise | undefined + let errorOverview: Promise | undefined + let featureFlagsData: Promise | undefined + let goalsData: Promise | undefined + let alertsData: Promise | undefined + let funnelData: Promise | undefined // Only fetch data if project is valid and not locked if (project && !project.isLocked && projectId) { @@ -268,8 +300,45 @@ export async function loader({ request, params }: LoaderFunctionArgs) { perfOverallStats = getPerformanceOverallStatsServer(request, [projectId], perfParams).then((res) => res.data) } else if (tab === PROJECT_TABS.sessions) { sessionsData = getSessionsServer(request, projectId, analyticsParams).then((res) => res.data) + // Fetch session details if psid is in URL + const psid = url.searchParams.get('psid') + if (psid) { + sessionDetails = getSessionServer(request, projectId, psid, timezone, password || undefined).then( + (res) => res.data, + ) + } } else if (tab === PROJECT_TABS.errors) { errorsData = getErrorsServer(request, projectId, analyticsParams).then((res) => res.data) + errorOverview = getErrorOverviewServer(request, projectId, analyticsParams).then((res) => res.data) + // Fetch error details if eid is in URL + const eid = url.searchParams.get('eid') + if (eid) { + errorDetails = getErrorServer(request, projectId, eid, analyticsParams).then((res) => res.data) + } + } else if (tab === PROJECT_TABS.featureFlags) { + featureFlagsData = getProjectFeatureFlagsServer(request, projectId).then((res) => res.data) + } else if (tab === PROJECT_TABS.goals) { + goalsData = getProjectGoalsServer(request, projectId).then((res) => res.data) + } else if (tab === PROJECT_TABS.alerts) { + alertsData = getProjectAlertsServer(request, projectId).then((res) => res.data) + } else if (tab === PROJECT_TABS.funnels) { + // Fetch funnel data if funnelId is in URL + const funnelId = url.searchParams.get('funnelId') + if (funnelId) { + const funnelFrom = from || '' + const funnelTo = to || '' + const funnelPeriod = period === 'custom' ? '' : period + funnelData = getFunnelDataServer( + request, + projectId, + funnelId, + funnelPeriod, + funnelFrom, + funnelTo, + timezone, + password || undefined, + ).then((res) => res.data) + } } } @@ -285,7 +354,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) { perfData, perfOverallStats, sessionsData, + sessionDetails, errorsData, + errorDetails, + errorOverview, + featureFlagsData, + goalsData, + alertsData, + funnelData, }, { headers: createHeadersWithCookies(cookies) }, ) From d3e7a7ca93d68185839aa4077d3cf560cbaed160 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 00:09:42 +0000 Subject: [PATCH 23/57] move more stuff --- web/app/api/api.server.ts | 319 +++++++++++++++++- web/app/api/index.ts | 294 +--------------- web/app/hooks/useAnalyticsProxy.ts | 285 +++++++++++++++- .../Project/tabs/Captcha/CaptchaView.tsx | 33 +- .../tabs/Experiments/ExperimentResults.tsx | 29 +- .../Experiments/ExperimentSettingsModal.tsx | 132 +++++--- .../Project/tabs/Goals/GoalSettingsModal.tsx | 8 +- .../Project/tabs/Profiles/ProfilesView.tsx | 123 +++---- web/app/routes/api.analytics.ts | 195 ++++++++++- 9 files changed, 962 insertions(+), 456 deletions(-) diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 612283333..b49065666 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -935,7 +935,7 @@ export async function getErrorOverviewServer( // MARK: Feature Flags API (Deferred Loading) // ============================================================================ -export interface TargetingRule { +interface TargetingRule { column: string filter: string isExclusive: boolean @@ -1139,7 +1139,7 @@ type QueryTime = | 'last_24_hours' | 'last_48_hours' -export interface Alert { +interface Alert { id: string name: string active: boolean @@ -1178,7 +1178,7 @@ export async function getProjectAlertsServer( // MARK: Funnels API (Deferred Loading) // ============================================================================ -export interface AnalyticsFunnelStep { +interface AnalyticsFunnelStep { value: string events: number eventsPerc: number @@ -1219,3 +1219,316 @@ export async function getFunnelDataServer( return serverFetch(request, `log/funnel?${queryParams.toString()}`, { headers }) } + +// ============================================================================ +// MARK: Captcha API (Deferred Loading) +// ============================================================================ + +interface CaptchaPanelData { + name: string + count: number + cc?: string +} + +interface CaptchaChartData { + x: string[] + results: number[] +} + +export interface CaptchaDataResponse { + params: Record + chart?: CaptchaChartData +} + +export async function getCaptchaDataServer( + request: Request, + pid: string, + timeBucket = 'hour', + period = '3d', + filters: AnalyticsFilter[] = [], + from = '', + to = '', + password?: string, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + timeBucket, + period, + filters: JSON.stringify(filters), + from, + to, + }) + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/captcha?${queryParams.toString()}`, { headers }) +} + +// ============================================================================ +// MARK: Experiments API (Deferred Loading) +// ============================================================================ + +type ExperimentStatus = 'draft' | 'running' | 'paused' | 'completed' + +export type ExposureTrigger = 'feature_flag' | 'custom_event' + +export type MultipleVariantHandling = 'exclude' | 'first_exposure' + +export type FeatureFlagMode = 'create' | 'link' + +export interface ExperimentVariant { + id?: string + name: string + key: string + description?: string | null + rolloutPercentage: number + isControl: boolean +} + +export interface Experiment { + id: string + name: string + description: string | null + hypothesis?: string | null + status: ExperimentStatus + startedAt: string | null + endedAt: string | null + featureFlagKey: string | null + exposureTrigger: ExposureTrigger + customEventName?: string | null + multipleVariantHandling: MultipleVariantHandling + featureFlagMode: FeatureFlagMode + featureFlagId: string | null + goalId: string | null + variants: ExperimentVariant[] + pid: string + created: string +} + +export async function getExperimentServer( + request: Request, + experimentId: string, +): Promise> { + return serverFetch(request, `experiment/${experimentId}`) +} + +export interface ExperimentVariantResult { + key: string + name: string + isControl: boolean + exposures: number + conversions: number + conversionRate: number + improvement: number + probabilityOfBeingBest: number +} + +export interface ExperimentChartData { + x: string[] + winProbability: Record +} + +export interface ExperimentResults { + experimentId: string + status: ExperimentStatus + hasWinner: boolean + winnerKey: string | null + totalExposures: number + totalConversions: number + confidenceLevel: number + variants: ExperimentVariantResult[] + chart?: ExperimentChartData +} + +export async function getExperimentResultsServer( + request: Request, + experimentId: string, + period: string, + timeBucket: string, + from = '', + to = '', + timezone?: string, +): Promise> { + const params = new URLSearchParams({ period, timeBucket }) + if (from) params.append('from', from) + if (to) params.append('to', to) + if (timezone) params.append('timezone', timezone) + + return serverFetch(request, `experiment/${experimentId}/results?${params.toString()}`) +} + +export async function getGoalServer(request: Request, goalId: string): Promise> { + return serverFetch(request, `goal/${goalId}`) +} + +// ============================================================================ +// MARK: Profiles API (Deferred Loading) +// ============================================================================ + +interface Profile { + id: string + isAnonymous: boolean + created: string + pageviews: number + sessions: number + customEvents: number + errors: number + revenue?: number + refunds?: number + revenueCurrency?: string + lastSeen: string + country?: string + city?: string + region?: string + properties?: Record +} + +export interface ProfilesResponse { + profiles: Profile[] +} + +export async function getProfilesServer( + request: Request, + pid: string, + period = '3d', + filters: AnalyticsFilter[] = [], + from = '', + to = '', + take = 30, + skip = 0, + timezone = '', + profileType: 'all' | 'anonymous' | 'identified' = 'all', + password?: string, +): Promise> { + const params = new URLSearchParams({ + pid, + take: String(take), + skip: String(skip), + period, + filters: JSON.stringify(filters), + from, + to, + timezone, + profileType, + }) + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/profiles?${params.toString()}`, { headers }) +} + +interface ProfileDetails { + profileId: string + isAnonymous: boolean + created: string + pageviews: number + sessions: number + customEvents: number + errors: number + revenue?: number + refunds?: number + revenueCurrency?: string + lastSeen: string + country?: string + city?: string + region?: string + locale?: string + os?: string + browser?: string + device?: string + properties?: Record + eventsInPeriod?: number + sessionsInPeriod?: number +} + +export interface ProfileDetailsResponse extends ProfileDetails { + chart: unknown + timeBucket: string +} + +export async function getProfileServer( + request: Request, + pid: string, + profileId: string, + period = '7d', + from = '', + to = '', + timezone = '', + password?: string, +): Promise> { + const params = new URLSearchParams({ + pid, + profileId: encodeURIComponent(profileId), + period, + from, + to, + timezone, + }) + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/profile?${params.toString()}`, { headers }) +} + +interface ProfileSession { + psid: string + cc: string | null + os: string | null + br: string | null + dv: string | null + pageviews: number + customEvents: number + errors: number + revenue?: number + refunds?: number + created: string + duration?: number + active?: boolean + isLive?: boolean +} + +export interface ProfileSessionsResponse { + sessions: ProfileSession[] +} + +export async function getProfileSessionsServer( + request: Request, + pid: string, + profileId: string, + period = '3d', + filters: AnalyticsFilter[] = [], + from = '', + to = '', + take = 30, + skip = 0, + timezone = '', + password?: string, +): Promise> { + const params = new URLSearchParams({ + pid, + profileId: encodeURIComponent(profileId), + take: String(take), + skip: String(skip), + period, + filters: JSON.stringify(filters), + from, + to, + timezone, + }) + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/profile/sessions?${params.toString()}`, { headers }) +} diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 1efca5953..4e9aafc09 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -5,9 +5,8 @@ import _map from 'lodash/map' import { API_URL } from '~/lib/constants' import { SSOProvider } from '~/lib/models/Auth' -import { Project, Overall, LiveStats, Session, Profile, ProfileDetails } from '~/lib/models/Project' +import { Project, Overall, LiveStats } from '~/lib/models/Project' import { User } from '~/lib/models/User' -import { Filter } from '~/pages/Project/View/interfaces/traffic' import { getAccessToken, setAccessToken } from '~/utils/accessToken' import { clearLocalStorageOnLogout } from '~/utils/auth' import { getRefreshToken } from '~/utils/refreshToken' @@ -147,85 +146,6 @@ export const getPropertyMetadata = ( throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -export const getProfiles = ( - pid: string, - period = '3d', - filters: any[] = [], - from = '', - to = '', - take = 30, - skip = 0, - timezone = '', - profileType: 'all' | 'anonymous' | 'identified' = 'all', - password: string | undefined = '', -) => - api - .get( - `log/profiles?pid=${pid}&take=${take}&skip=${skip}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}&profileType=${profileType}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): { profiles: Profile[]; take: number; skip: number; appliedFilters: Filter[] } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getProfile = ( - pid: string, - profileId: string, - period = '7d', - from = '', - to = '', - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/profile?pid=${pid}&profileId=${encodeURIComponent(profileId)}&period=${period}&from=${from}&to=${to}&timezone=${timezone}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): ProfileDetails & { chart: any; timeBucket: string } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getProfileSessions = ( - pid: string, - profileId: string, - period = '3d', - filters: any[] = [], - from = '', - to = '', - take = 30, - skip = 0, - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/profile/sessions?pid=${pid}&profileId=${encodeURIComponent(profileId)}&take=${take}&skip=${skip}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): { sessions: Session[]; take: number; skip: number; appliedFilters: Filter[] } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export interface ErrorAffectedSession { psid: string profileId: string | null @@ -262,18 +182,6 @@ export const getErrorSessions = ( throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message }) -export const getCaptchaData = (pid: string, tb = 'hour', period = '3d', filters: any[] = [], from = '', to = '') => - api - .get( - `log/captcha?pid=${pid}&timeBucket=${tb}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}`, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getOverallStats = ( pids: string[], tb: string, @@ -333,68 +241,6 @@ export const getLiveVisitorsInfo = (pid: string, password?: string) => throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message }) -// Goals API -export interface Goal { - id: string - name: string - type: 'pageview' | 'custom_event' - matchType: 'exact' | 'contains' - value: string | null - metadataFilters: { key: string; value: string }[] | null - active: boolean - pid: string - created: string -} - -export interface GoalStats { - conversions: number - uniqueSessions: number - conversionRate: number - previousConversions: number - trend: number -} - -export interface GoalChartData { - x: string[] - conversions: number[] - uniqueSessions: number[] -} - -export const DEFAULT_GOALS_TAKE = 20 - -export const getProjectGoals = (projectId: string, take: number = DEFAULT_GOALS_TAKE, skip = 0, search?: string) => { - const params = new URLSearchParams({ - take: String(take), - skip: String(skip), - }) - - if (search?.trim()) { - params.append('search', search.trim()) - } - - return api - .get(`/goal/project/${projectId}?${params.toString()}`) - .then( - ( - response, - ): { - results: Goal[] - total: number - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) -} - -export const getGoal = (goalId: string) => - api - .get(`/goal/${goalId}`) - .then((response): Goal => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - // Feature Flags API export interface TargetingRule { column: string @@ -414,57 +260,17 @@ export interface ProjectFeatureFlag { created: string } -export interface FeatureFlagStats { - evaluations: number - profileCount: number - trueCount: number - falseCount: number - truePercentage: number -} - -export const DEFAULT_FEATURE_FLAGS_TAKE = 20 - -export const getProjectFeatureFlags = ( - projectId: string, - take: number = DEFAULT_FEATURE_FLAGS_TAKE, - skip = 0, - search?: string, -) => { - const params = new URLSearchParams({ - take: String(take), - skip: String(skip), - }) - - if (search?.trim()) { - params.append('search', search.trim()) - } - - return api - .get(`/feature-flag/project/${projectId}?${params.toString()}`) - .then( - ( - response, - ): { - results: ProjectFeatureFlag[] - total: number - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) -} - // Experiments (A/B Testing) API -export type ExperimentStatus = 'draft' | 'running' | 'paused' | 'completed' +type ExperimentStatus = 'draft' | 'running' | 'paused' | 'completed' -export type ExposureTrigger = 'feature_flag' | 'custom_event' +type ExposureTrigger = 'feature_flag' | 'custom_event' -export type MultipleVariantHandling = 'exclude' | 'first_exposure' +type MultipleVariantHandling = 'exclude' | 'first_exposure' -export type FeatureFlagMode = 'create' | 'link' +type FeatureFlagMode = 'create' | 'link' -export interface ExperimentVariant { +interface ExperimentVariant { id?: string name: string key: string @@ -496,96 +302,8 @@ export interface Experiment { created: string } -export interface ExperimentVariantResult { - key: string - name: string - isControl: boolean - exposures: number - conversions: number - conversionRate: number - probabilityOfBeingBest: number - improvement: number -} - -export interface ExperimentChartData { - x: string[] - winProbability: Record -} - -export interface ExperimentResults { - experimentId: string - status: ExperimentStatus - variants: ExperimentVariantResult[] - totalExposures: number - totalConversions: number - hasWinner: boolean - winnerKey: string | null - confidenceLevel: number - chart?: ExperimentChartData - timeBucket?: string[] -} - -interface CreateExperiment { - pid: string - name: string - description?: string - hypothesis?: string - // Exposure criteria - exposureTrigger?: ExposureTrigger - customEventName?: string - multipleVariantHandling?: MultipleVariantHandling - filterInternalUsers?: boolean - // Feature flag configuration - featureFlagMode?: FeatureFlagMode - featureFlagKey?: string - existingFeatureFlagId?: string - goalId?: string - variants: ExperimentVariant[] -} - export const DEFAULT_EXPERIMENTS_TAKE = 20 -export const getExperiment = (experimentId: string) => - api - .get(`/experiment/${experimentId}`) - .then((response): Experiment => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const createExperiment = (data: CreateExperiment) => - api - .post('experiment', data) - .then((response): Experiment => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const updateExperiment = (id: string, data: Partial) => - api - .put(`experiment/${id}`, data) - .then((response): Experiment => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getExperimentResults = ( - experimentId: string, - period: string, - timeBucket: string, - from: string = '', - to: string = '', - timezone?: string, -) => - api - .get(`/experiment/${experimentId}/results`, { - params: { period, timeBucket, from, to, timezone }, - }) - .then((response): ExperimentResults => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProjectDataCustomEvents = ( pid: string, tb = 'hour', diff --git a/web/app/hooks/useAnalyticsProxy.ts b/web/app/hooks/useAnalyticsProxy.ts index 74320da38..97b59364f 100644 --- a/web/app/hooks/useAnalyticsProxy.ts +++ b/web/app/hooks/useAnalyticsProxy.ts @@ -8,11 +8,20 @@ import type { GoalStats, GoalChartData, AnalyticsFilter, + CaptchaDataResponse, + ExperimentResults, + Experiment, + Goal, + GoalsResponse, + FeatureFlagsResponse, + ProfilesResponse, + ProfileDetailsResponse, + ProfileSessionsResponse, } from '~/api/api.server' interface AnalyticsParams { timeBucket?: string - period: string + period?: string from?: string to?: string timezone?: string @@ -21,6 +30,7 @@ interface AnalyticsParams { skip?: number options?: Record resultFilter?: string + search?: string } interface ProxyResponse { @@ -203,3 +213,276 @@ export function useGoalChartProxy() { return { fetchChart, data, error, isLoading } } + +export function useCaptchaProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchCaptcha = useCallback(async (projectId: string, params: AnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getCaptchaData', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchCaptcha, data, error, isLoading } +} + +export function useExperimentResultsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchResults = useCallback(async (experimentId: string, params: AnalyticsParams) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getExperimentResults', projectId: '', experimentId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchResults, data, error, isLoading } +} + +export function useExperimentProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchExperiment = useCallback(async (experimentId: string) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getExperiment', projectId: '', experimentId, params: {} }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchExperiment, data, error, isLoading } +} + +export function useGoalProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchGoal = useCallback(async (goalId: string) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getGoal', projectId: '', goalId, params: {} }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchGoal, data, error, isLoading } +} + +export function useProjectGoalsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchGoals = useCallback(async (projectId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getProjectGoals', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchGoals, data, error, isLoading } +} + +export function useProjectFeatureFlagsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchFeatureFlags = useCallback(async (projectId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getProjectFeatureFlags', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchFeatureFlags, data, error, isLoading } +} + +export function useProfilesProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchProfiles = useCallback( + async (projectId: string, params: AnalyticsParams & { profileType?: 'all' | 'anonymous' | 'identified' } = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getProfiles', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, + [], + ) + + return { fetchProfiles, data, error, isLoading } +} + +export function useProfileProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchProfile = useCallback(async (projectId: string, profileId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getProfile', projectId, profileId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchProfile, data, error, isLoading } +} + +export function useProfileSessionsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchProfileSessions = useCallback(async (projectId: string, profileId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getProfileSessions', projectId, profileId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchProfileSessions, data, error, isLoading } +} diff --git a/web/app/pages/Project/tabs/Captcha/CaptchaView.tsx b/web/app/pages/Project/tabs/Captcha/CaptchaView.tsx index 8dcd73efc..c974c18ac 100644 --- a/web/app/pages/Project/tabs/Captcha/CaptchaView.tsx +++ b/web/app/pages/Project/tabs/Captcha/CaptchaView.tsx @@ -4,11 +4,11 @@ import _isEmpty from 'lodash/isEmpty' import _keys from 'lodash/keys' import _map from 'lodash/map' import { CompassIcon, MapPinIcon, MonitorCog, TabletSmartphoneIcon } from 'lucide-react' -import { useState, useEffect, useMemo, useContext } from 'react' +import { useState, useEffect, useMemo, useContext, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router' -import { getCaptchaData } from '~/api' +import { useCaptchaProxy } from '~/hooks/useAnalyticsProxy' import useSize from '~/hooks/useSize' import { chartTypes, BROWSER_LOGO_MAP, OS_LOGO_MAP, OS_LOGO_MAP_DARK } from '~/lib/constants' import { useCurrentProject } from '~/providers/CurrentProjectProvider' @@ -56,6 +56,8 @@ const CaptchaView = ({ projectId }: CaptchaViewProps) => { const { period, timeBucket, dateRange, captchaRefreshTrigger, timeFormat, size } = useContext(ViewProjectContext) const [searchParams] = useSearchParams() const isEmbedded = searchParams.get('embedded') === 'true' + const isMountedRef = useRef(true) + const captchaProxy = useCaptchaProxy() const { t, @@ -84,6 +86,13 @@ const CaptchaView = ({ projectId }: CaptchaViewProps) => { const tnMapping = captchaTypeNameMapping(t) const [ref] = useSize() as any + useEffect(() => { + isMountedRef.current = true + return () => { + isMountedRef.current = false + } + }, []) + const getFormatDate = (date: Date) => { const yyyy = date.getFullYear() let mm: string | number = date.getMonth() + 1 @@ -97,20 +106,23 @@ const CaptchaView = ({ projectId }: CaptchaViewProps) => { setDataLoading(true) try { - let data - let from - let to + let from = '' + let to = '' if (dateRange) { from = getFormatDate(dateRange[0]) to = getFormatDate(dateRange[1]) } - if (period === 'custom' && dateRange) { - data = await getCaptchaData(projectId, timeBucket, '', filters, from, to) - } else { - data = await getCaptchaData(projectId, timeBucket, period, filters, '', '') - } + const data = await captchaProxy.fetchCaptcha(projectId, { + timeBucket, + period: period === 'custom' && dateRange ? '' : period, + filters, + from: period === 'custom' && dateRange ? from : '', + to: period === 'custom' && dateRange ? to : '', + }) + + if (!isMountedRef.current) return if (_isEmpty(data)) { setAnalyticsLoading(false) @@ -141,6 +153,7 @@ const CaptchaView = ({ projectId }: CaptchaViewProps) => { setAnalyticsLoading(false) setDataLoading(false) } catch (reason) { + if (!isMountedRef.current) return setAnalyticsLoading(false) setDataLoading(false) setIsPanelsDataEmpty(true) diff --git a/web/app/pages/Project/tabs/Experiments/ExperimentResults.tsx b/web/app/pages/Project/tabs/Experiments/ExperimentResults.tsx index a2d464d81..0f1d87a21 100644 --- a/web/app/pages/Project/tabs/Experiments/ExperimentResults.tsx +++ b/web/app/pages/Project/tabs/Experiments/ExperimentResults.tsx @@ -22,16 +22,14 @@ import { import { useState, useEffect, useRef, useMemo, memo } from 'react' import { useTranslation } from 'react-i18next' -import { - getExperiment, - getExperimentResults, - getGoal, - type Experiment, - type ExperimentResults as ExperimentResultsType, - type ExperimentChartData, - type ExperimentVariantResult, - type Goal, -} from '~/api' +import type { + Experiment, + ExperimentResults as ExperimentResultsType, + ExperimentChartData, + ExperimentVariantResult, + Goal, +} from '~/api/api.server' +import { useExperimentProxy, useExperimentResultsProxy, useGoalProxy } from '~/hooks/useAnalyticsProxy' import { TimeFormat, tbsFormatMapper, @@ -619,6 +617,9 @@ const ExperimentResults = ({ }: ExperimentResultsProps) => { const { t } = useTranslation() const isMountedRef = useRef(true) + const experimentProxy = useExperimentProxy() + const resultsProxy = useExperimentResultsProxy() + const goalProxy = useGoalProxy() const [experiment, setExperiment] = useState(null) const [results, setResults] = useState(null) @@ -645,14 +646,14 @@ const ExperimentResults = ({ try { const [experimentData, resultsData] = await Promise.all([ - getExperiment(experimentId), - getExperimentResults(experimentId, period, timeBucket, from, to, timezone), + experimentProxy.fetchExperiment(experimentId), + resultsProxy.fetchResults(experimentId, { period, timeBucket, from, to, timezone }), ]) let goalData: Goal | null = null - if (experimentData.goalId) { + if (experimentData?.goalId) { try { - goalData = await getGoal(experimentData.goalId) + goalData = await goalProxy.fetchGoal(experimentData.goalId) } catch { goalData = null } diff --git a/web/app/pages/Project/tabs/Experiments/ExperimentSettingsModal.tsx b/web/app/pages/Project/tabs/Experiments/ExperimentSettingsModal.tsx index 8b0d0bc04..40bc909b1 100644 --- a/web/app/pages/Project/tabs/Experiments/ExperimentSettingsModal.tsx +++ b/web/app/pages/Project/tabs/Experiments/ExperimentSettingsModal.tsx @@ -4,23 +4,21 @@ import cx from 'clsx' import _map from 'lodash/map' import _sum from 'lodash/sum' import { PlusIcon, Trash2Icon, ChevronDownIcon, TrendingUpIcon, TrendingDownIcon } from 'lucide-react' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { - createExperiment, - updateExperiment, - getExperiment, - getProjectGoals, - getProjectFeatureFlags, - type ExperimentVariant, - type Goal, - type ProjectFeatureFlag, - type ExposureTrigger, - type MultipleVariantHandling, - type FeatureFlagMode, -} from '~/api' +import type { + ExperimentVariant, + Goal, + ProjectFeatureFlag, + ExposureTrigger, + MultipleVariantHandling, + FeatureFlagMode, +} from '~/api/api.server' +import { useExperimentProxy, useProjectGoalsProxy, useProjectFeatureFlagsProxy } from '~/hooks/useAnalyticsProxy' +import type { ProjectViewActionData } from '~/routes/projects.$id' import Button from '~/ui/Button' import Input from '~/ui/Input' import Loader from '~/ui/Loader' @@ -52,14 +50,20 @@ const ExperimentSettingsModal = ({ }: ExperimentSettingsModalProps) => { const { t } = useTranslation() const isEditing = !!experimentId + const experimentProxy = useExperimentProxy() + const goalsProxy = useProjectGoalsProxy() + const featureFlagsProxy = useProjectFeatureFlagsProxy() + const fetcher = useFetcher() + const processedRef = useRef(null) const [isLoading, setIsLoading] = useState(false) - const [isSaving, setIsSaving] = useState(false) const [goals, setGoals] = useState([]) const [goalsLoading, setGoalsLoading] = useState(false) const [featureFlags, setFeatureFlags] = useState([]) const [featureFlagsLoading, setFeatureFlagsLoading] = useState(false) + const isSaving = fetcher.state === 'submitting' + const [name, setName] = useState('') const [description, setDescription] = useState('') const [featureFlagKey, setFeatureFlagKey] = useState('') @@ -105,7 +109,10 @@ const ExperimentSettingsModal = ({ if (!experimentId) return setIsLoading(true) try { - const experiment = await getExperiment(experimentId) + const experiment = await experimentProxy.fetchExperiment(experimentId) + if (!experiment) { + throw new Error('Failed to load experiment') + } setName(experiment.name) setDescription(experiment.description || '') setFeatureFlagKey(experiment.featureFlagKey || '') @@ -150,8 +157,10 @@ const ExperimentSettingsModal = ({ const loadGoals = async () => { setGoalsLoading(true) try { - const result = await getProjectGoals(projectId, 100, 0) - setGoals(result.results) + const result = await goalsProxy.fetchGoals(projectId, { take: 100, skip: 0 }) + if (result) { + setGoals(result.results) + } } catch (error) { console.error('Failed to load goals:', error) } finally { @@ -160,7 +169,7 @@ const ExperimentSettingsModal = ({ } loadGoals() - }, [isOpen, projectId]) + }, [isOpen, projectId, goalsProxy]) useEffect(() => { if (!isOpen || featureFlagMode !== 'link') return @@ -168,8 +177,10 @@ const ExperimentSettingsModal = ({ const loadFeatureFlags = async () => { setFeatureFlagsLoading(true) try { - const result = await getProjectFeatureFlags(projectId, 100, 0) - setFeatureFlags(result.results) + const result = await featureFlagsProxy.fetchFeatureFlags(projectId, { take: 100, skip: 0 }) + if (result) { + setFeatureFlags(result.results) + } } catch (error) { console.error('Failed to load feature flags:', error) } finally { @@ -178,7 +189,7 @@ const ExperimentSettingsModal = ({ } loadFeatureFlags() - }, [isOpen, projectId, featureFlagMode]) + }, [isOpen, projectId, featureFlagMode, featureFlagsProxy]) const validateForm = (): boolean => { const newErrors: Record = {} @@ -226,43 +237,60 @@ const ExperimentSettingsModal = ({ return Object.keys(newErrors).length === 0 } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!validateForm()) return + // Handle fetcher response + useEffect(() => { + if (!fetcher.data || fetcher.state !== 'idle') return - setIsSaving(true) - try { - const effectiveFlagKey = featureFlagKey.trim() || suggestedFlagKey - - const data = { - pid: projectId, - name: name.trim(), - description: description.trim() || undefined, - exposureTrigger, - customEventName: exposureTrigger === 'custom_event' ? customEventName.trim() : undefined, - multipleVariantHandling, - featureFlagMode, - featureFlagKey: featureFlagMode === 'create' ? effectiveFlagKey || undefined : undefined, - existingFeatureFlagId: featureFlagMode === 'link' ? existingFeatureFlagId : undefined, - goalId: goalId || undefined, - variants, - } + const responseKey = `${fetcher.data.intent}-${fetcher.data.success}` + if (processedRef.current === responseKey) return + processedRef.current = responseKey - if (experimentId) { - await updateExperiment(experimentId, data) - toast.success(t('experiments.updated')) - } else { - await createExperiment(data) + if (fetcher.data.success) { + const { intent } = fetcher.data + if (intent === 'create-experiment') { toast.success(t('experiments.created')) + } else if (intent === 'update-experiment') { + toast.success(t('experiments.updated')) } - onSuccess() onClose() - } catch (error: any) { - toast.error(error?.response?.data?.message || error?.message || t('apiNotifications.somethingWentWrong')) - } finally { - setIsSaving(false) + } else if (fetcher.data.error) { + toast.error(fetcher.data.error) + } + }, [fetcher.data, fetcher.state, t, onSuccess, onClose]) + + // Reset processed ref when modal opens + useEffect(() => { + if (isOpen) { + processedRef.current = null } + }, [isOpen]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!validateForm()) return + + processedRef.current = null + const effectiveFlagKey = featureFlagKey.trim() || suggestedFlagKey + + const formData = new FormData() + formData.set('intent', isEditing ? 'update-experiment' : 'create-experiment') + formData.set('name', name.trim()) + formData.set('description', description.trim()) + formData.set('exposureTrigger', exposureTrigger) + formData.set('customEventName', exposureTrigger === 'custom_event' ? customEventName.trim() : '') + formData.set('multipleVariantHandling', multipleVariantHandling) + formData.set('featureFlagMode', featureFlagMode) + formData.set('featureFlagKey', featureFlagMode === 'create' ? effectiveFlagKey : '') + formData.set('existingFeatureFlagId', featureFlagMode === 'link' ? existingFeatureFlagId : '') + formData.set('goalId', goalId) + formData.set('variants', JSON.stringify(variants)) + + if (isEditing && experimentId) { + formData.set('experimentId', experimentId) + } + + fetcher.submit(formData, { method: 'post' }) } const distributePercentages = (variantsList: ExperimentVariant[]): ExperimentVariant[] => { diff --git a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx index 88bd4eada..9370f33bc 100644 --- a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { getGoal } from '~/api' +import { useGoalProxy } from '~/hooks/useAnalyticsProxy' import type { ProjectViewActionData } from '~/routes/projects.$id' import Button from '~/ui/Button' import Input from '~/ui/Input' @@ -36,6 +36,7 @@ const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: Go const { t } = useTranslation() const fetcher = useFetcher() const processedRef = useRef(null) + const goalProxy = useGoalProxy() const isNew = !goalId const [isLoading, setIsLoading] = useState(false) @@ -61,7 +62,10 @@ const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: Go if (!goalId) return setIsLoading(true) try { - const goal = await getGoal(goalId) + const goal = await goalProxy.fetchGoal(goalId) + if (!goal) { + throw new Error('Failed to load goal') + } setName(goal.name) setType(goal.type) setMatchType(goal.matchType) diff --git a/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx b/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx index 056321227..87a7f3c66 100644 --- a/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx +++ b/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx @@ -5,7 +5,7 @@ import { useState, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router' -import { getProfiles, getProfile, getProfileSessions } from '~/api' +import { useProfilesProxy, useProfileProxy, useProfileSessionsProxy } from '~/hooks/useAnalyticsProxy' import { Profile, ProfileDetails as ProfileDetailsType, Session } from '~/lib/models/Project' import NoProfiles from '~/pages/Project/tabs/Profiles/components/NoProfiles' import { ProfileDetails } from '~/pages/Project/tabs/Profiles/ProfileDetails' @@ -31,6 +31,9 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { const { timezone, period, dateRange, filters, timeFormat, profilesRefreshTrigger } = useViewProjectContext() const { t } = useTranslation('common') const [searchParams] = useSearchParams() + const profilesProxy = useProfilesProxy() + const profileProxy = useProfileProxy() + const profileSessionsProxy = useProfileSessionsProxy() // Profiles state const [profilesSkip, setProfilesSkip] = useState(0) @@ -72,48 +75,31 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { try { const skip = typeof forcedSkip === 'number' ? forcedSkip : profilesSkip - let dataProfiles: { profiles: Profile[] } - let from - let to + let from = '' + let to = '' if (dateRange) { from = getFormatDate(dateRange[0]) to = getFormatDate(dateRange[1]) } - if (period === 'custom' && dateRange) { - dataProfiles = await getProfiles( - id, - '', - filters, - from, - to, - SESSIONS_TAKE, - skip, - timezone, - profileTypeFilter, - projectPassword, - ) - } else { - dataProfiles = await getProfiles( - id, - period, - filters, - '', - '', - SESSIONS_TAKE, - skip, - timezone, - profileTypeFilter, - projectPassword, - ) - } + const dataProfiles = await profilesProxy.fetchProfiles(id, { + period: period === 'custom' && dateRange ? '' : period, + filters, + from: period === 'custom' && dateRange ? from : '', + to: period === 'custom' && dateRange ? to : '', + take: SESSIONS_TAKE, + skip, + timezone, + profileType: profileTypeFilter, + }) if (requestId === profilesRequestIdRef.current && isMountedRef.current) { + const profilesList = (dataProfiles?.profiles || []) as unknown as Profile[] if (override) { - setProfiles(dataProfiles?.profiles || []) + setProfiles(profilesList) } else { - setProfiles((prev) => [...prev, ...(dataProfiles?.profiles || [])]) + setProfiles((prev) => [...prev, ...profilesList]) } setProfilesSkip((prev) => { if (typeof forcedSkip === 'number') { @@ -122,7 +108,7 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { return SESSIONS_TAKE + prev }) - if (dataProfiles?.profiles?.length < SESSIONS_TAKE) { + if (!dataProfiles?.profiles || dataProfiles.profiles.length < SESSIONS_TAKE) { setCanLoadMoreProfiles(false) } else { setCanLoadMoreProfiles(true) @@ -150,26 +136,23 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { } try { - let from - let to + let from = '' + let to = '' if (dateRange) { from = getFormatDate(dateRange[0]) to = getFormatDate(dateRange[1]) } - const data = await getProfile( - id, - profileId, - period === 'custom' ? '' : period, - from || '', - to || '', + const data = await profileProxy.fetchProfile(id, profileId, { + period: period === 'custom' ? '' : period, + from, + to, timezone, - projectPassword, - ) + }) - if (isMountedRef.current) { - setActiveProfile(data) + if (isMountedRef.current && data) { + setActiveProfile(data as unknown as ProfileDetailsType) // Load initial sessions for the profile (override existing on refresh) loadProfileSessionsData(profileId, 0, true) } @@ -194,48 +177,30 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { try { const skip = typeof forcedSkip === 'number' ? forcedSkip : profileSessionsSkip - let dataSessions: { sessions: Session[] } - let from - let to + let from = '' + let to = '' if (dateRange) { from = getFormatDate(dateRange[0]) to = getFormatDate(dateRange[1]) } - if (period === 'custom' && dateRange) { - dataSessions = await getProfileSessions( - id, - profileId, - '', - filters, - from, - to, - SESSIONS_TAKE, - skip, - timezone, - projectPassword, - ) - } else { - dataSessions = await getProfileSessions( - id, - profileId, - period, - filters, - '', - '', - SESSIONS_TAKE, - skip, - timezone, - projectPassword, - ) - } + const dataSessions = await profileSessionsProxy.fetchProfileSessions(id, profileId, { + period: period === 'custom' && dateRange ? '' : period, + filters, + from: period === 'custom' && dateRange ? from : '', + to: period === 'custom' && dateRange ? to : '', + take: SESSIONS_TAKE, + skip, + timezone, + }) if (isMountedRef.current) { + const sessionsList = (dataSessions?.sessions || []) as unknown as Session[] if (override) { - setProfileSessions(dataSessions?.sessions || []) + setProfileSessions(sessionsList) } else { - setProfileSessions((prev) => [...prev, ...(dataSessions?.sessions || [])]) + setProfileSessions((prev) => [...prev, ...sessionsList]) } setProfileSessionsSkip((prev) => { if (typeof forcedSkip === 'number') { @@ -244,7 +209,7 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { return SESSIONS_TAKE + prev }) - if (dataSessions?.sessions?.length < SESSIONS_TAKE) { + if (!dataSessions?.sessions || dataSessions.sessions.length < SESSIONS_TAKE) { setCanLoadMoreProfileSessions(false) } else { setCanLoadMoreProfileSessions(true) diff --git a/web/app/routes/api.analytics.ts b/web/app/routes/api.analytics.ts index 51d19f89b..c8627936e 100644 --- a/web/app/routes/api.analytics.ts +++ b/web/app/routes/api.analytics.ts @@ -7,13 +7,32 @@ import { getFeatureFlagProfilesServer, getGoalStatsServer, getGoalChartServer, + getCaptchaDataServer, + getExperimentResultsServer, + getExperimentServer, + getGoalServer, + getProjectGoalsServer, + getProjectFeatureFlagsServer, + getProfilesServer, + getProfileServer, + getProfileSessionsServer, type AnalyticsParams, + type AnalyticsFilter, type SessionsResponse, type ErrorsResponse, type FeatureFlagStats, type FeatureFlagProfilesResponse, type GoalStats, type GoalChartData, + type CaptchaDataResponse, + type ExperimentResults, + type Experiment, + type Goal, + type GoalsResponse, + type FeatureFlagsResponse, + type ProfilesResponse, + type ProfileDetailsResponse, + type ProfileSessionsResponse, } from '~/api/api.server' import { getProjectPasswordCookie } from '~/utils/session.server' @@ -25,20 +44,33 @@ interface ProxyRequest { | 'getFeatureFlagProfiles' | 'getGoalStats' | 'getGoalChart' + | 'getCaptchaData' + | 'getExperimentResults' + | 'getExperiment' + | 'getGoal' + | 'getProjectGoals' + | 'getProjectFeatureFlags' + | 'getProfiles' + | 'getProfile' + | 'getProfileSessions' projectId: string flagId?: string goalId?: string + experimentId?: string + profileId?: string params: { timeBucket?: string - period: string + period?: string from?: string to?: string timezone?: string - filters?: AnalyticsParams['filters'] + filters?: AnalyticsFilter[] take?: number skip?: number options?: Record resultFilter?: string + search?: string + profileType?: 'all' | 'anonymous' | 'identified' } } @@ -56,7 +88,7 @@ export async function action({ request }: ActionFunctionArgs) { const analyticsParams: AnalyticsParams & { take?: number; skip?: number; options?: Record } = { timeBucket: params.timeBucket || 'day', - period: params.period, + period: params.period || '7d', from: params.from, to: params.to, timezone: params.timezone || 'UTC', @@ -92,7 +124,7 @@ export async function action({ request }: ActionFunctionArgs) { const result = await getFeatureFlagStatsServer( request, body.flagId, - params.period, + params.period || '7d', params.from || '', params.to || '', params.timezone, @@ -110,7 +142,7 @@ export async function action({ request }: ActionFunctionArgs) { const result = await getFeatureFlagProfilesServer( request, body.flagId, - params.period, + params.period || '7d', params.from || '', params.to || '', params.timezone, @@ -131,7 +163,7 @@ export async function action({ request }: ActionFunctionArgs) { const result = await getGoalStatsServer( request, body.goalId, - params.period, + params.period || '7d', params.from || '', params.to || '', params.timezone, @@ -149,7 +181,7 @@ export async function action({ request }: ActionFunctionArgs) { const result = await getGoalChartServer( request, body.goalId, - params.period, + params.period || '7d', params.from || '', params.to || '', params.timeBucket || 'day', @@ -161,6 +193,155 @@ export async function action({ request }: ActionFunctionArgs) { }) } + case 'getCaptchaData': { + const result = await getCaptchaDataServer( + request, + projectId, + params.timeBucket || 'hour', + params.period || '3d', + params.filters || [], + params.from || '', + params.to || '', + password || undefined, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getExperimentResults': { + if (!body.experimentId) { + return data>({ data: null, error: 'experimentId is required' }, { status: 400 }) + } + const result = await getExperimentResultsServer( + request, + body.experimentId, + params.period || '7d', + params.timeBucket || 'day', + params.from || '', + params.to || '', + params.timezone, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getExperiment': { + if (!body.experimentId) { + return data>({ data: null, error: 'experimentId is required' }, { status: 400 }) + } + const result = await getExperimentServer(request, body.experimentId) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getGoal': { + if (!body.goalId) { + return data>({ data: null, error: 'goalId is required' }, { status: 400 }) + } + const result = await getGoalServer(request, body.goalId) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getProjectGoals': { + const result = await getProjectGoalsServer( + request, + projectId, + params.take || 100, + params.skip || 0, + params.search, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getProjectFeatureFlags': { + const result = await getProjectFeatureFlagsServer( + request, + projectId, + params.take || 100, + params.skip || 0, + params.search, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getProfiles': { + const result = await getProfilesServer( + request, + projectId, + params.period || '3d', + params.filters || [], + params.from || '', + params.to || '', + params.take || 30, + params.skip || 0, + params.timezone || '', + params.profileType || 'all', + password || undefined, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getProfile': { + if (!body.profileId) { + return data>({ data: null, error: 'profileId is required' }, { status: 400 }) + } + const result = await getProfileServer( + request, + projectId, + body.profileId, + params.period || '7d', + params.from || '', + params.to || '', + params.timezone || '', + password || undefined, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getProfileSessions': { + if (!body.profileId) { + return data>({ data: null, error: 'profileId is required' }, { status: 400 }) + } + const result = await getProfileSessionsServer( + request, + projectId, + body.profileId, + params.period || '3d', + params.filters || [], + params.from || '', + params.to || '', + params.take || 30, + params.skip || 0, + params.timezone || '', + password || undefined, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + default: return data>({ data: null, error: `Unknown action: ${action}` }, { status: 400 }) } From 8e5a81dd52a26a2fed37e589928b4e3f246f59dd Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 00:21:29 +0000 Subject: [PATCH 24/57] ssr dashboard & onboarding --- web/app/api/index.ts | 16 ---- web/app/pages/Dashboard/Dashboard.tsx | 114 ++++++------------------ web/app/pages/Dashboard/ProjectCard.tsx | 2 +- web/app/pages/Onboarding/Onboarding.tsx | 66 ++++++-------- web/app/routes/onboarding.tsx | 39 +++++++- web/app/utils/accessToken.ts | 11 +-- web/app/utils/refreshToken.ts | 11 +-- 7 files changed, 93 insertions(+), 166 deletions(-) diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 4e9aafc09..59f9d3567 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -67,22 +67,6 @@ export const authMe = (config?: AxiosRequestConfig) => } => response.data, ) -export const getProjects = (take = 0, skip = 0, search = '', period = '7d', sort = 'alpha_asc') => - api - .get(`/project?take=${take}&skip=${skip}&search=${search}&period=${period}&sort=${sort}`) - .then( - ( - response, - ): { - results: Project[] - total: number - page_total: number - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - export const getProject = (pid: string, password?: string) => api .get(`/project/${pid}`, { diff --git a/web/app/pages/Dashboard/Dashboard.tsx b/web/app/pages/Dashboard/Dashboard.tsx index 6b2ce37b9..5a60db7b1 100644 --- a/web/app/pages/Dashboard/Dashboard.tsx +++ b/web/app/pages/Dashboard/Dashboard.tsx @@ -6,24 +6,23 @@ import _size from 'lodash/size' import { StretchHorizontalIcon, LayoutGridIcon, SearchIcon, XIcon, FolderPlusIcon } from 'lucide-react' import React, { useState, useEffect, useRef, useMemo, memo } from 'react' import { useTranslation } from 'react-i18next' -import { useLoaderData, useNavigate, useSearchParams, useFetcher } from 'react-router' +import { useLoaderData, useNavigate, useSearchParams, useFetcher, useNavigation, useRevalidator } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { getProjects, getLiveVisitors, getOverallStats } from '~/api' +import { getLiveVisitors, getOverallStats } from '~/api' import DashboardLockedBanner from '~/components/DashboardLockedBanner' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' import useBreakpoint from '~/hooks/useBreakpoint' import useDebounce from '~/hooks/useDebounce' import { isSelfhosted, LIVE_VISITORS_UPDATE_INTERVAL, tbPeriodPairs } from '~/lib/constants' -import { Overall, Project } from '~/lib/models/Project' +import { Overall } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' import type { DashboardLoaderData, DashboardActionData } from '~/routes/dashboard' import Input from '~/ui/Input' import Modal from '~/ui/Modal' import Pagination from '~/ui/Pagination' import Select from '~/ui/Select' -import StatusPage from '~/ui/StatusPage' import { Text } from '~/ui/Text' import { setCookie } from '~/utils/cookie' import routes from '~/utils/routes' @@ -47,6 +46,8 @@ const DEFAULT_PROJECT_NAME = 'Untitled Project' const Dashboard = () => { const loaderData = useLoaderData() const navigate = useNavigate() + const navigation = useNavigation() + const revalidator = useRevalidator() const [searchParams, setSearchParams] = useSearchParams() const fetcher = useFetcher() @@ -59,9 +60,8 @@ const Dashboard = () => { const [viewMode, setViewMode] = useState(loaderData.viewMode) const isAboveLgBreakpoint = useBreakpoint('lg') - // Initialize projects from loader data - const [projects, setProjects] = useState(loaderData.projects?.results || []) - const [paginationTotal, setPaginationTotal] = useState(loaderData.projects?.total || 0) + const projects = useMemo(() => loaderData.projects?.results || [], [loaderData.projects?.results]) + const paginationTotal = loaderData.projects?.total || 0 const page = useMemo(() => { const pageParam = searchParams.get('page') @@ -79,28 +79,25 @@ const Dashboard = () => { return parsedPage }, [searchParams]) - const [pageSize, setPageSize] = useState(() => { + const pageSize = useMemo(() => { const pageSizeParam = searchParams.get('pageSize') return pageSizeParam && PAGE_SIZE_OPTIONS.includes(parseInt(pageSizeParam, 10)) ? parseInt(pageSizeParam, 10) : PAGE_SIZE_OPTIONS[0] - }) + }, [searchParams]) - // Start with false since we have initial data from loader - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) + const isLoading = navigation.state === 'loading' || revalidator.state === 'loading' const [liveStats, setLiveStats] = useState>({}) const [overallStats, setOverallStats] = useState({}) - const [activePeriod, setActivePeriod] = useState(() => { - const periodParam = searchParams.get('period') - return periodParam || '7d' - }) + const activePeriod = useMemo(() => { + return searchParams.get('period') || '7d' + }, [searchParams]) - const [sortBy, setSortBy] = useState(() => { + const sortBy = useMemo(() => { const sortParam = searchParams.get('sort') return sortParam && Object.values(SORT_OPTIONS).includes(sortParam as any) ? sortParam : SORT_OPTIONS.ALPHA_ASC - }) + }, [searchParams]) // New project modal state const [newProjectModalOpen, setNewProjectModalOpen] = useState(false) @@ -125,51 +122,34 @@ const Dashboard = () => { const pageAmount = Math.ceil(paginationTotal / pageSize) // This search represents what's inside the search input - const [search, setSearch] = useState('') + const [search, setSearch] = useState(searchParams.get('search') || '') const debouncedSearch = useDebounce(search, 500) - // Track if user has interacted (changed page, search, etc.) - need to refetch - const [hasUserInteracted, setHasUserInteracted] = useState(false) - - // Update URL only when values are explicitly changed const updateURL = (params: Record) => { const newSearchParams = new URLSearchParams(searchParams) Object.entries(params).forEach(([key, value]) => { - newSearchParams.set(key, value) + if (value) { + newSearchParams.set(key, value) + } else { + newSearchParams.delete(key) + } }) setSearchParams(newSearchParams) } - const setPage = (newPage: number) => { - const newSearchParams = new URLSearchParams(searchParams) - newSearchParams.set('page', newPage.toString()) - setSearchParams(newSearchParams) - } - const handlePageChange = (newPage: number) => { - setHasUserInteracted(true) - setPage(newPage) updateURL({ page: newPage.toString() }) } const handlePageSizeChange = (size: number) => { - setHasUserInteracted(true) - setPageSize(size) - setPage(1) updateURL({ pageSize: size.toString(), page: '1' }) } const handlePeriodChange = (period: string) => { - setHasUserInteracted(true) - setActivePeriod(period) - setPage(1) updateURL({ period, page: '1' }) } const handleSortChange = (sort: string) => { - setHasUserInteracted(true) - setSortBy(sort) - setPage(1) updateURL({ sort, page: '1' }) } @@ -249,25 +229,8 @@ const Dashboard = () => { setNewProjectBeenSubmitted(false) } - const refetchProjects = async () => { - await loadProjects(pageSize, (page - 1) * pageSize, debouncedSearch, activePeriod, sortBy) - } - - const loadProjects = async (take: number, skip: number, search?: string, period?: string, sort?: string) => { - if (isLoading) { - return - } - setIsLoading(true) - - try { - const result = await getProjects(take, skip, search, period, sort) - setProjects(result.results) - setPaginationTotal(result.total) - } catch (reason: any) { - setError(reason) - } finally { - setIsLoading(false) - } + const refetchProjects = () => { + revalidator.revalidate() } // Redirect to onboarding if user hasn't completed it @@ -278,22 +241,13 @@ const Dashboard = () => { } }, [user, navigate]) - // Only fetch on user interaction (search, pagination, sort, period changes) + // Update URL when debounced search changes useEffect(() => { - if (!hasUserInteracted) { - return + const currentSearch = searchParams.get('search') || '' + if (debouncedSearch !== currentSearch) { + updateURL({ search: debouncedSearch, page: '1' }) } - - loadProjects(pageSize, (page - 1) * pageSize, debouncedSearch, activePeriod, sortBy) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, pageSize, debouncedSearch, activePeriod, sortBy, hasUserInteracted]) - - // Track search changes as user interaction - useEffect(() => { - if (debouncedSearch !== '') { - setHasUserInteracted(true) - } }, [debouncedSearch]) // Set up interval for live visitors @@ -344,20 +298,6 @@ const Dashboard = () => { return () => clearInterval(interval) }, [projects, activePeriod, t]) // Reset interval when projects change - if (error && !isLoading) { - return ( - window.location.reload(), primary: true }, - { label: t('notFoundPage.support'), to: routes.contact }, - ]} - /> - ) - } - const onSearch = (e: React.ChangeEvent) => { setSearch(e.target.value) } diff --git a/web/app/pages/Dashboard/ProjectCard.tsx b/web/app/pages/Dashboard/ProjectCard.tsx index 339d47757..048298c9c 100644 --- a/web/app/pages/Dashboard/ProjectCard.tsx +++ b/web/app/pages/Dashboard/ProjectCard.tsx @@ -46,7 +46,7 @@ interface ProjectCardProps { project: Project activePeriod: string viewMode: 'grid' | 'list' - refetchProjects: () => Promise + refetchProjects: () => void } interface MiniCardProps { diff --git a/web/app/pages/Onboarding/Onboarding.tsx b/web/app/pages/Onboarding/Onboarding.tsx index 8790cab30..8a65c38c4 100644 --- a/web/app/pages/Onboarding/Onboarding.tsx +++ b/web/app/pages/Onboarding/Onboarding.tsx @@ -4,15 +4,15 @@ import type { TFunction } from 'i18next' import { ChevronRightIcon, LaptopMinimalIcon, RocketIcon, CodeIcon, MailCheckIcon, SparklesIcon } from 'lucide-react' import { useState, useEffect, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { Link, useNavigate, useFetcher } from 'react-router' +import { Link, useNavigate, useFetcher, useLoaderData } from 'react-router' import { toast } from 'sonner' -import { authMe, getProjects } from '~/api' +import { authMe } from '~/api' import { CONTACT_US_URL } from '~/components/Footer' import { DOCS_URL, INTEGRATIONS_URL, isSelfhosted } from '~/lib/constants' -import type { OnboardingActionData } from '~/routes/onboarding' import { getSnippet } from '~/modals/TrackingSnippet' import { useAuth } from '~/providers/AuthProvider' +import type { OnboardingActionData, OnboardingLoaderData } from '~/routes/onboarding' import { Badge } from '~/ui/Badge' import Button from '~/ui/Button' import PulsatingCircle from '~/ui/icons/PulsatingCircle' @@ -20,10 +20,8 @@ import Input from '~/ui/Input' import Loader from '~/ui/Loader' import { Text } from '~/ui/Text' import Textarea from '~/ui/Textarea' -import { removeAccessToken } from '~/utils/accessToken' import { trackCustom } from '~/utils/analytics' import { cn } from '~/utils/generic' -import { removeRefreshToken } from '~/utils/refreshToken' import routes from '~/utils/routes' const MAX_PROJECT_NAME_LENGTH = 50 @@ -70,13 +68,14 @@ interface Project { const Onboarding = () => { const { t } = useTranslation('common') + const loaderData = useLoaderData() const { user, loadUser, logout } = useAuth() const navigate = useNavigate() const fetcher = useFetcher() const [currentStep, setCurrentStep] = useState(0) const [projectName, setProjectName] = useState('') - const [project, setProject] = useState(null) + const [project, setProject] = useState(loaderData.project) const [isWaitingForEvents, setIsWaitingForEvents] = useState(false) const [hasEvents, setHasEvents] = useState(false) @@ -121,43 +120,30 @@ const Onboarding = () => { return } - const determineCurrentStep = async () => { - if (!user?.isActive && !isSelfhosted) { - setCurrentStep(0) - return - } - - if (!user?.onboardingStep) { - setCurrentStep(1) - return - } + if (!user?.isActive && !isSelfhosted) { + setCurrentStep(0) + return + } - switch (user.onboardingStep) { - case 'create_project': - setCurrentStep(1) - break - case 'setup_tracking': - setCurrentStep(2) - try { - const projectsData = await getProjects(1, 0) - if (projectsData.results.length > 0) { - setProject(projectsData.results[0]) - setCurrentStep(2) - } - } catch (reason) { - console.error('Failed to get projects:', reason) - } - break - case 'waiting_for_events': - setCurrentStep(3) - setIsWaitingForEvents(true) - break - default: - setCurrentStep(1) - } + if (!user?.onboardingStep) { + setCurrentStep(1) + return } - determineCurrentStep() + switch (user.onboardingStep) { + case 'create_project': + setCurrentStep(1) + break + case 'setup_tracking': + setCurrentStep(2) + break + case 'waiting_for_events': + setCurrentStep(3) + setIsWaitingForEvents(true) + break + default: + setCurrentStep(1) + } }, [user, navigate]) useEffect(() => { diff --git a/web/app/routes/onboarding.tsx b/web/app/routes/onboarding.tsx index 72eb190af..09dc1887c 100644 --- a/web/app/routes/onboarding.tsx +++ b/web/app/routes/onboarding.tsx @@ -2,7 +2,7 @@ import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from 'react import { data } from 'react-router' import { SitemapFunction } from 'remix-sitemap' -import { serverFetch } from '~/api/api.server' +import { getAuthenticatedUser, serverFetch } from '~/api/api.server' import { Project } from '~/lib/models/Project' import { User } from '~/lib/models/User' import Onboarding from '~/pages/Onboarding' @@ -19,9 +19,44 @@ export const meta: MetaFunction = () => { ] } +export interface OnboardingLoaderData { + project: Project | null +} + export async function loader({ request }: LoaderFunctionArgs) { redirectIfNotAuthenticated(request) - return null + + const authResult = await getAuthenticatedUser(request) + const user = authResult?.user + const cookies: string[] = authResult?.cookies || [] + + if (user?.onboardingStep === 'setup_tracking' || user?.onboardingStep === 'waiting_for_events') { + const projectsResult = await serverFetch<{ + results: Project[] + total: number + }>(request, '/project?take=1&skip=0') + + const project = projectsResult.data?.results?.[0] || null + const allCookies = [...cookies, ...projectsResult.cookies] + + const loaderData: OnboardingLoaderData = { project } + + if (allCookies.length > 0) { + return data(loaderData, { + headers: createHeadersWithCookies(allCookies), + }) + } + + return loaderData + } + + if (cookies.length > 0) { + return data({ project: null } as OnboardingLoaderData, { + headers: createHeadersWithCookies(cookies), + }) + } + + return { project: null } as OnboardingLoaderData } export interface OnboardingActionData { diff --git a/web/app/utils/accessToken.ts b/web/app/utils/accessToken.ts index 456438658..1b176a9c2 100644 --- a/web/app/utils/accessToken.ts +++ b/web/app/utils/accessToken.ts @@ -2,7 +2,7 @@ import _isEmpty from 'lodash/isEmpty' import { TOKEN, isBrowser } from '~/lib/constants' -import { getCookie, setCookie, deleteCookie } from './cookie' +import { getCookie, setCookie } from './cookie' // 14 weeks in seconds const STORE_AUTH_TOKEN_FOR = 8467200 @@ -34,12 +34,3 @@ export const setAccessToken = (token: string, temporary = false) => { setCookie(TOKEN, token, STORE_AUTH_TOKEN_FOR) } - -export const removeAccessToken = () => { - if (!isBrowser) { - return null - } - - deleteCookie(TOKEN) - sessionStorage.removeItem(TOKEN) -} diff --git a/web/app/utils/refreshToken.ts b/web/app/utils/refreshToken.ts index 24d600849..19c9de822 100644 --- a/web/app/utils/refreshToken.ts +++ b/web/app/utils/refreshToken.ts @@ -2,7 +2,7 @@ import _isEmpty from 'lodash/isEmpty' import { REFRESH_TOKEN, isBrowser } from '~/lib/constants' -import { getCookie, setCookie, deleteCookie } from './cookie' +import { getCookie, setCookie } from './cookie' // 14 weeks in seconds const STORE_REFRESH_TOKEN_FOR = 8467200 @@ -33,12 +33,3 @@ export const setRefreshToken = (token: string, temporary = false) => { setCookie(REFRESH_TOKEN, token, STORE_REFRESH_TOKEN_FOR) } - -export const removeRefreshToken = () => { - if (!isBrowser) { - return null - } - - deleteCookie(REFRESH_TOKEN) - sessionStorage.removeItem(REFRESH_TOKEN) -} From 6f150a1efe95055749a5a5d80e8dd2d3519ca588 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 03:30:14 +0000 Subject: [PATCH 25/57] move the rest to ssr --- web/app/api/api.server.ts | 686 +++++++++++++++++- web/app/api/index.ts | 499 +------------ web/app/hooks/useAnalyticsProxy.ts | 391 +++++++++- web/app/hooks/useAuthProxy.ts | 121 +++ web/app/lib/models/Project.ts | 2 - web/app/pages/Auth/Signin/Signin.tsx | 11 +- web/app/pages/Auth/Signup/Signup.tsx | 7 +- web/app/pages/Auth/Socialised/index.tsx | 3 +- web/app/pages/Dashboard/Dashboard.tsx | 57 +- web/app/pages/Onboarding/Onboarding.tsx | 5 +- .../Project/Settings/ProjectSettings.tsx | 45 +- web/app/pages/Project/View/Panels.tsx | 17 +- .../Project/View/components/AddAViewModal.tsx | 9 +- .../View/components/LiveVisitorsDropdown.tsx | 13 +- .../Project/View/components/SearchFilters.tsx | 23 +- .../Project/tabs/Errors/ErrorDetails.tsx | 30 +- .../FeatureFlags/FeatureFlagSettingsModal.tsx | 10 +- .../Project/tabs/Traffic/TrafficView.tsx | 74 +- .../pages/Project/tabs/Traffic/UserFlow.tsx | 19 +- .../components/Socialisations.tsx | 3 +- web/app/providers/AuthProvider.tsx | 11 +- web/app/providers/CurrentProjectProvider.tsx | 12 +- web/app/routes/api.ai.ts | 40 + web/app/routes/api.analytics.ts | 264 ++++++- web/app/routes/api.auth.ts | 154 ++++ web/app/routes/blog.$slug.tsx | 7 +- web/app/routes/gsc-connected.tsx | 5 +- web/app/utils/compareConvertDate.ts | 97 --- web/app/utils/cookie.ts | 9 - 29 files changed, 1845 insertions(+), 779 deletions(-) create mode 100644 web/app/hooks/useAuthProxy.ts create mode 100644 web/app/routes/api.ai.ts create mode 100644 web/app/routes/api.auth.ts delete mode 100644 web/app/utils/compareConvertDate.ts diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index b49065666..8c4ff5379 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -177,6 +177,56 @@ export async function serverFetch( } } +export async function streamingServerFetch( + request: Request, + endpoint: string, + options: ServerFetchOptions = {}, +): Promise { + const { method = 'GET', body, headers: customHeaders = {} } = options + + const apiUrl = getApiUrl() + const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint + const url = `${apiUrl}${cleanEndpoint}` + + const fetchHeaders: Record = { + 'Content-Type': 'application/json', + ...customHeaders, + } + + const clientIP = getClientIP(request) + if (clientIP) { + fetchHeaders['X-Client-IP-Address'] = clientIP + } + + const accessToken = getAccessToken(request) + if (accessToken) { + fetchHeaders['Authorization'] = `Bearer ${accessToken}` + } + + const response = await fetch(url, { + method, + headers: fetchHeaders, + body: body ? JSON.stringify(body) : undefined, + }) + + // If 401/403, try to refresh the token and retry + if (response.status === 401 || response.status === 403) { + const refreshResult = await tryRefreshToken(request) + + if (refreshResult.success && refreshResult.tokens) { + fetchHeaders['Authorization'] = `Bearer ${refreshResult.tokens.accessToken}` + + return fetch(url, { + method, + headers: fetchHeaders, + body: body ? JSON.stringify(body) : undefined, + }) + } + } + + return response +} + async function tryRefreshToken( request: Request, ): Promise<{ success: boolean; tokens?: AuthTokens; cookies: string[] }> { @@ -442,14 +492,17 @@ export async function getBlogPostWithCategory( return result.data } -export async function getSitemap(request: Request): Promise<(string | string[])[] | null> { - const result = await serverFetch<(string | string[])[]>(request, 'v1/blog/sitemap', { skipAuth: true }) +// ============================================================================ +// MARK: Auth API +// ============================================================================ - if (result.error || !result.data) { - return null - } +export interface AuthMeResponse { + user: User + totalMonthlyEvents: number +} - return result.data +export async function authMeServer(request: Request): Promise> { + return serverFetch(request, 'user/me') } // ============================================================================ @@ -561,7 +614,7 @@ export async function getProjectDataServer( export async function getOverallStatsServer( request: Request, pids: string[], - params: AnalyticsParams, + params: AnalyticsParams & { includeChart?: boolean }, ): Promise>> { const pidsParam = `[${pids.map((pid) => `"${pid}"`).join(',')}]` @@ -573,7 +626,7 @@ export async function getOverallStatsServer( to: params.to || '', timezone: params.timezone, filters: serializeFiltersForUrl(params.filters), - includeChart: 'false', + includeChart: params.includeChart !== false ? 'true' : 'false', }) const headers: Record = {} @@ -1532,3 +1585,620 @@ export async function getProfileSessionsServer( return serverFetch(request, `log/profile/sessions?${params.toString()}`, { headers }) } + +// ============================================================================ +// MARK: Projects API +// ============================================================================ + +export interface Project { + id: string + name: string + origins: string[] | null + ipBlacklist: string[] | null + active: boolean + public: boolean + isPasswordProtected: boolean + passwordHash: string | null + admin: { + id: string + email: string + } + created: string + isOwner: boolean + isLocked: boolean + isCaptchaProject: boolean + isCaptchaEnabled: boolean + isAnalyticsProject: boolean + captchaSecretKey: string | null + role: string + botsProtectionLevel: string + isTransferring: boolean + share?: { + id: string + confirmed: boolean + role: string + } + funnels?: { id: string; name: string }[] +} + +export async function getProjectServer( + request: Request, + pid: string, + password?: string, +): Promise> { + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `project/${pid}`, { headers }) +} + +// ============================================================================ +// MARK: Custom Events Metadata API +// ============================================================================ + +export interface CustomEventsMetadataResponse { + result: Array<{ key: string; value: string; count: number }> +} + +export async function getCustomEventsMetadataServer( + request: Request, + pid: string, + event: string, + params: { + timeBucket?: string + period?: string + from?: string + to?: string + timezone?: string + password?: string + } = {}, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + event, + timeBucket: params.timeBucket || 'hour', + period: params.period || '1d', + from: params.from || '', + to: params.to || '', + timezone: params.timezone || '', + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/meta?${queryParams.toString()}`, { headers }) +} + +// ============================================================================ +// MARK: Property Metadata API +// ============================================================================ + +export interface PropertyMetadataResponse { + result: Array<{ key: string; value: string; count: number }> +} + +export async function getPropertyMetadataServer( + request: Request, + pid: string, + property: string, + params: { + timeBucket?: string + period?: string + from?: string + to?: string + filters?: AnalyticsFilter[] + timezone?: string + password?: string + } = {}, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + property, + timeBucket: params.timeBucket || 'hour', + period: params.period || '1d', + from: params.from || '', + to: params.to || '', + timezone: params.timezone || '', + filters: JSON.stringify(params.filters || []), + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/property?${queryParams.toString()}`, { headers }) +} + +// ============================================================================ +// MARK: Error Sessions API +// ============================================================================ + +export interface ErrorAffectedSession { + psid: string + profileId: string | null + cc: string | null + br: string | null + os: string | null + firstErrorAt: string + lastErrorAt: string + errorCount: number +} + +export interface ErrorSessionsResponse { + sessions: ErrorAffectedSession[] + total: number +} + +export async function getErrorSessionsServer( + request: Request, + pid: string, + eid: string, + params: { + timeBucket?: string + period?: string + from?: string + to?: string + take?: number + skip?: number + password?: string + } = {}, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + eid, + timeBucket: params.timeBucket || 'hour', + period: params.period || '7d', + from: params.from || '', + to: params.to || '', + take: String(params.take || 10), + skip: String(params.skip || 0), + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/error-sessions?${queryParams.toString()}`, { headers }) +} + +// ============================================================================ +// MARK: Live Visitors API +// ============================================================================ + +export interface LiveStats { + [pid: string]: number +} + +export async function getLiveVisitorsServer( + request: Request, + pids: string[], + password?: string, +): Promise> { + const pidsParam = `[${pids.map((pid) => `"${pid}"`).join(',')}]` + + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/hb?pids=${pidsParam}`, { headers }) +} + +export interface LiveVisitorInfo { + psid: string + dv: string + br: string + os: string + cc: string +} + +export async function getLiveVisitorsInfoServer( + request: Request, + pid: string, + password?: string, +): Promise> { + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/live-visitors?pid=${pid}`, { headers }) +} + +// ============================================================================ +// MARK: Custom Events Chart API +// ============================================================================ + +export interface ProjectDataCustomEventsResponse { + chart?: { + events?: Record + } + customs?: Record +} + +export async function getProjectDataCustomEventsServer( + request: Request, + pid: string, + params: { + timeBucket?: string + period?: string + filters?: AnalyticsFilter[] + from?: string + to?: string + timezone?: string + customEvents?: string[] + password?: string + } = {}, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + timeBucket: params.timeBucket || 'hour', + period: params.period || '3d', + filters: JSON.stringify(params.filters || []), + from: params.from || '', + to: params.to || '', + timezone: params.timezone || '', + customEvents: JSON.stringify(params.customEvents || []), + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/custom-events?${queryParams.toString()}`, { + headers, + }) +} + +// ============================================================================ +// MARK: SSO API +// ============================================================================ + +export type SSOProvider = 'google' | 'github' | 'openid-connect' + +export interface SSOAuthURLResponse { + uuid: string + auth_url: string + expires_in: number +} + +export async function generateSSOAuthURLServer( + request: Request, + provider: SSOProvider, + redirectUrl?: string, +): Promise> { + const payload: { provider: SSOProvider; redirectUrl?: string } = { provider } + if (redirectUrl) { + payload.redirectUrl = redirectUrl + } + + const endpoint = provider === 'openid-connect' ? 'v1/auth/oidc/initiate' : 'v1/auth/sso/generate' + + return serverFetch(request, endpoint, { + method: 'POST', + body: payload, + skipAuth: true, + }) +} + +export interface SSOHashResponse { + accessToken: string + refreshToken: string + user: User + totalMonthlyEvents: number +} + +export async function getJWTBySSOHashServer( + request: Request, + hash: string, + provider: SSOProvider, +): Promise> { + const endpoint = provider === 'openid-connect' ? 'v1/auth/oidc/hash' : 'v1/auth/sso/hash' + const body = provider === 'openid-connect' ? { hash } : { hash, provider } + + return serverFetch(request, endpoint, { + method: 'POST', + body, + skipAuth: true, + }) +} + +export async function processSSOTokenCommunityEditionServer( + request: Request, + code: string, + hash: string, + redirectUrl: string, +): Promise> { + return serverFetch(request, 'v1/auth/oidc/process-token', { + method: 'POST', + body: { code, hash, redirectUrl }, + skipAuth: true, + }) +} + +export async function linkBySSOHashServer( + request: Request, + hash: string, + provider: SSOProvider, +): Promise> { + return serverFetch(request, 'v1/auth/sso/link_by_hash', { + method: 'POST', + body: { hash, provider }, + }) +} + +export async function processSSOTokenServer( + request: Request, + token: string, + hash: string, +): Promise> { + return serverFetch(request, 'v1/auth/sso/process-token', { + method: 'POST', + body: { token, hash }, + skipAuth: true, + }) +} + +// ============================================================================ +// MARK: User Flow API +// ============================================================================ + +interface UserFlowNode { + id: string + name: string + count: number +} + +interface UserFlowLink { + source: string + target: string + value: number +} + +export interface UserFlowResponse { + nodes: UserFlowNode[] + links: UserFlowLink[] +} + +export async function getUserFlowServer( + request: Request, + pid: string, + params: { + timeBucket?: string + period?: string + filters?: AnalyticsFilter[] + from?: string + to?: string + timezone?: string + password?: string + } = {}, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + timeBucket: params.timeBucket || 'hour', + period: params.period || '3d', + filters: JSON.stringify(params.filters || []), + from: params.from || '', + to: params.to || '', + timezone: params.timezone || '', + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/user-flow?${queryParams.toString()}`, { headers }) +} + +// ============================================================================ +// MARK: Filters API +// ============================================================================ + +export async function getFiltersServer( + request: Request, + pid: string, + type: string, + password?: string, +): Promise> { + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/filters?pid=${pid}&type=${type}`, { headers }) +} + +export async function getErrorsFiltersServer( + request: Request, + pid: string, + type: string, + password?: string, +): Promise> { + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/errors-filters?pid=${pid}&type=${type}`, { headers }) +} + +export interface VersionFilter { + name: string + version: string +} + +export async function getVersionFiltersServer( + request: Request, + pid: string, + type: 'traffic' | 'errors', + column: 'br' | 'os', + password?: string, +): Promise> { + const headers: Record = {} + if (password) { + headers['x-password'] = password + } + + return serverFetch(request, `log/filters/versions?pid=${pid}&type=${type}&column=${column}`, { + headers, + }) +} + +// ============================================================================ +// MARK: Google Search Console API +// ============================================================================ + +export async function processGSCTokenServer( + request: Request, + code: string, + state: string, +): Promise> { + return serverFetch<{ pid: string }>(request, 'v1/project/gsc/process-token', { + method: 'POST', + body: { code, state }, + }) +} + +interface GSCKeyword { + name: string + count: number + impressions: number + position: number + ctr: number +} + +export interface GSCKeywordsResponse { + keywords: GSCKeyword[] + notConnected?: boolean +} + +export async function getGSCKeywordsServer( + request: Request, + pid: string, + params: { + period?: string + from?: string + to?: string + timezone?: string + password?: string + } = {}, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + period: params.period || '3d', + from: params.from || '', + to: params.to || '', + timezone: params.timezone || '', + }) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch(request, `log/keywords?${queryParams.toString()}`, { headers }) +} + +// ============================================================================ +// MARK: Revenue API +// ============================================================================ + +export interface RevenueStatus { + connected: boolean + provider?: string + currency?: string + lastSyncAt?: string +} + +export async function getRevenueStatusServer(request: Request, pid: string): Promise> { + return serverFetch(request, `project/${pid}/revenue/status`) +} + +interface RevenueStats { + totalRevenue: number + salesCount: number + refundsCount: number + refundsAmount: number + averageOrderValue: number + currency: string + mrr: number + revenueChange: number +} + +interface RevenueChart { + x: string[] + revenue: number[] + salesCount: number[] + refundsAmount: number[] +} + +export interface RevenueDataResponse { + stats: RevenueStats + chart: RevenueChart +} + +export async function getRevenueDataServer( + request: Request, + pid: string, + params: { + period: string + from?: string + to?: string + timezone?: string + timeBucket?: string + }, +): Promise> { + const queryParams = new URLSearchParams({ + pid, + period: params.period, + }) + if (params.from) queryParams.append('from', params.from) + if (params.to) queryParams.append('to', params.to) + if (params.timezone) queryParams.append('timezone', params.timezone) + if (params.timeBucket) queryParams.append('timeBucket', params.timeBucket) + + return serverFetch(request, `log/revenue?${queryParams.toString()}`) +} + +// ============================================================================ +// MARK: AI Chat API (Proxy for streaming) +// ============================================================================ + +interface AIChatMessage { + role: 'user' | 'assistant' + content: string +} + +export async function askAIServer( + request: Request, + pid: string, + messages: AIChatMessage[], + timezone: string, +): Promise { + const apiUrl = getApiUrl() + const accessToken = getAccessToken(request) + + const fetchHeaders: Record = { + 'Content-Type': 'application/json', + } + + if (accessToken) { + fetchHeaders['Authorization'] = `Bearer ${accessToken}` + } + + const response = await fetch(`${apiUrl}ai/${pid}/chat`, { + method: 'POST', + headers: fetchHeaders, + body: JSON.stringify({ messages, timezone }), + }) + + return response +} diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 59f9d3567..2b9c22b05 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -1,230 +1,3 @@ -import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' -import createAuthRefreshInterceptor from 'axios-auth-refresh' -import _isEmpty from 'lodash/isEmpty' -import _map from 'lodash/map' - -import { API_URL } from '~/lib/constants' -import { SSOProvider } from '~/lib/models/Auth' -import { Project, Overall, LiveStats } from '~/lib/models/Project' -import { User } from '~/lib/models/User' -import { getAccessToken, setAccessToken } from '~/utils/accessToken' -import { clearLocalStorageOnLogout } from '~/utils/auth' -import { getRefreshToken } from '~/utils/refreshToken' - -const api = axios.create({ - baseURL: API_URL, - // Enable sending cookies with cross-origin requests - withCredentials: true, -}) - -// Function that will be called to refresh authorization -const refreshAuthLogic = (failedRequest: { response: AxiosResponse }) => - axios - .post(`${API_URL}v1/auth/refresh-token`, null, { - headers: { - Authorization: `Bearer ${getRefreshToken()}`, - }, - }) - .then((tokenRefreshResponse) => { - const { accessToken } = tokenRefreshResponse.data - setAccessToken(accessToken) - - failedRequest.response.config.headers.Authorization = `Bearer ${accessToken}` - return Promise.resolve() - }) - .catch((error) => { - clearLocalStorageOnLogout() - // Redirect to server-side logout which handles cookie cleanup - window.location.href = '/logout' - return Promise.reject(error) - }) - -// Instantiate the interceptor -createAuthRefreshInterceptor(api, refreshAuthLogic, { - statusCodes: [401, 403], -}) - -api.interceptors.request.use( - (config) => { - const token = getAccessToken() - if (token) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error) => { - return Promise.reject(error) - }, -) - -export const authMe = (config?: AxiosRequestConfig) => - api.get('user/me', config).then( - ( - response, - ): { - user: User - totalMonthlyEvents: number - } => response.data, - ) - -export const getProject = (pid: string, password?: string) => - api - .get(`/project/${pid}`, { - headers: { - 'x-password': password, - }, - }) - .then((response): Project => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getCustomEventsMetadata = ( - pid: string, - event: string, - tb = 'hour', - period = '1d', - from = '', - to = '', - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/meta?pid=${pid}&timeBucket=${tb}&period=${period}&from=${from}&to=${to}&timezone=${timezone}&event=${event}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getPropertyMetadata = ( - pid: string, - property: string, - tb = 'hour', - period = '1d', - from = '', - to = '', - filters: any[] = [], - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/property?pid=${pid}&timeBucket=${tb}&period=${period}&from=${from}&to=${to}&timezone=${timezone}&property=${property}&filters=${JSON.stringify( - filters, - )}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export interface ErrorAffectedSession { - psid: string - profileId: string | null - cc: string | null - br: string | null - os: string | null - firstErrorAt: string - lastErrorAt: string - errorCount: number -} - -export const getErrorSessions = ( - pid: string, - eid: string, - timeBucket = 'hour', - period = '7d', - from = '', - to = '', - take = 10, - skip = 0, - password: string | undefined = '', -) => - api - .get( - `log/error-sessions?pid=${pid}&eid=${eid}&timeBucket=${timeBucket}&period=${period}&from=${from}&to=${to}&take=${take}&skip=${skip}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): { sessions: ErrorAffectedSession[]; total: number } => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) - -export const getOverallStats = ( - pids: string[], - tb: string, - period: string, - from = '', - to = '', - timezone = 'Etc/GMT', - filters: any = '', - password?: string, - includeChart = false, -) => - api - .get( - `log/birdseye?pids=[${_map(pids, (pid) => `"${pid}"`).join( - ',', - )}]&timeBucket=${tb}&period=${period}&from=${from}&to=${to}&timezone=${timezone}&filters=${JSON.stringify(filters)}&includeChart=${includeChart}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response): Overall => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getLiveVisitors = (pids: string[], password?: string): Promise => - api - .get(`log/hb?pids=[${_map(pids, (pid) => `"${pid}"`).join(',')}]`, { - headers: { - 'x-password': password, - }, - }) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export interface GetLiveVisitorsInfo { - psid: string - dv: string - br: string - os: string - cc: string -} - -export const getLiveVisitorsInfo = (pid: string, password?: string) => - api - .get(`log/live-visitors?pid=${pid}`, { - headers: { - 'x-password': password, - }, - }) - .then((response): GetLiveVisitorsInfo[] => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - // Feature Flags API export interface TargetingRule { column: string @@ -288,206 +61,7 @@ export interface Experiment { export const DEFAULT_EXPERIMENTS_TAKE = 20 -export const getProjectDataCustomEvents = ( - pid: string, - tb = 'hour', - period = '3d', - filters: any[] = [], - from = '', - to = '', - timezone = '', - customEvents: string[] = [], - password: string | undefined = '', -) => - api - .get( - `log/custom-events?pid=${pid}&timeBucket=${tb}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}&customEvents=${JSON.stringify(customEvents)}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const generateSSOAuthURL = async (provider: SSOProvider, redirectUrl?: string) => { - const payload = { - provider, - redirectUrl, - } - - let authInitiateRequest - - if (provider === 'openid-connect') { - authInitiateRequest = api.post('v1/auth/oidc/initiate', payload) - } else { - authInitiateRequest = api.post('v1/auth/sso/generate', payload) - } - - return authInitiateRequest - .then( - ( - response, - ): { - uuid: string - auth_url: string - expires_in: number - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) -} - -export const getJWTBySSOHash = async (hash: string, provider: SSOProvider) => { - let axiosPost - - if (provider === 'openid-connect') { - axiosPost = api.post('v1/auth/oidc/hash', { hash }) - } else { - axiosPost = api.post('v1/auth/sso/hash', { hash, provider }) - } - - return axiosPost - .then( - ( - response, - ): { - accessToken: string - refreshToken: string - user: User - totalMonthlyEvents: number - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) -} - -export const processSSOTokenCommunityEdition = (code: string, hash: string, redirectUrl: string) => - api - .post('v1/auth/oidc/process-token', { code, hash, redirectUrl }) - .then((response): unknown => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const linkBySSOHash = (hash: string, provider: SSOProvider) => - api - .post('v1/auth/sso/link_by_hash', { hash, provider }) - .then((response): unknown => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const processSSOToken = (token: string, hash: string) => - api - .post('v1/auth/sso/process-token', { token, hash }) - .then((response): unknown => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getUserFlow = ( - pid: string, - tb = 'hour', - period = '3d', - filters: any[] = [], - from = '', - to = '', - timezone = '', - password: string | undefined = '', -) => - api - .get( - `log/user-flow?pid=${pid}&timeBucket=${tb}&period=${period}&filters=${JSON.stringify( - filters, - )}&from=${from}&to=${to}&timezone=${timezone}`, - { - headers: { - 'x-password': password, - }, - }, - ) - .then((response) => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getFilters = (pid: string, type: string, password = '') => - api - .get(`log/filters?pid=${pid}&type=${type}`, { - headers: { - 'x-password': password, - }, - }) - .then((response): string[] => response.data) - .catch((error) => { - throw error - }) - -export const getErrorsFilters = (pid: string, type: string, password = '') => - api - .get(`log/errors-filters?pid=${pid}&type=${type}`, { - headers: { - 'x-password': password, - }, - }) - .then((response): string[] => response.data) - .catch((error) => { - throw error - }) - -export const getVersionFilters = (pid: string, type: 'traffic' | 'errors', column: 'br' | 'os', password = '') => - api - .get(`log/filters/versions?pid=${pid}&type=${type}&column=${column}`, { - headers: { - 'x-password': password, - }, - }) - .then((response): Array<{ name: string; version: string }> => response.data) - .catch((error) => { - throw error - }) - -export const processGSCToken = (code: string, state: string) => - api - .post(`v1/project/gsc/process-token`, { code, state }) - .then((response): { pid: string } => response.data) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -export const getGSCKeywords = ( - pid: string, - period = '3d', - from = '', - to = '', - timezone = '', - password: string | undefined = '', -) => - api - .get(`log/keywords?pid=${pid}&period=${period}&from=${from}&to=${to}&timezone=${timezone}`, { - headers: { 'x-password': password }, - }) - .then( - ( - response, - ): { - keywords: { name: string; count: number; impressions: number; position: number; ctr: number }[] - notConnected?: boolean - } => response.data, - ) - .catch((error) => { - throw _isEmpty(error.response.data?.message) ? error.response.data : error.response.data.message - }) - -// AI Chat API with SSE streaming +// AI Chat API with SSE streaming (proxied through /api/ai) interface AIChatMessage { role: 'user' | 'assistant' content: string @@ -509,22 +83,19 @@ export const askAI = async ( callbacks: AIStreamCallbacks, signal?: AbortSignal, ) => { - const token = getAccessToken() - try { - const response = await fetch(`${API_URL}ai/${pid}/chat`, { + const response = await fetch('/api/ai', { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: token ? `Bearer ${token}` : '', }, - body: JSON.stringify({ messages, timezone }), + body: JSON.stringify({ pid, messages, timezone }), signal, }) if (!response.ok) { const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.message || `HTTP error! status: ${response.status}`) + throw new Error(errorData.message || errorData.error || `HTTP error! status: ${response.status}`) } const reader = response.body?.getReader() @@ -590,65 +161,3 @@ export interface AIChatSummary { created: string updated: string } - -// Revenue API -interface RevenueStatus { - connected: boolean - provider?: string - currency?: string - lastSyncAt?: string -} - -interface RevenueStats { - totalRevenue: number - salesCount: number - refundsCount: number - refundsAmount: number - averageOrderValue: number - currency: string - mrr: number - revenueChange: number -} - -interface RevenueChart { - x: string[] - revenue: number[] - salesCount: number[] - refundsAmount: number[] -} - -export const getRevenueStatus = async (pid: string): Promise => { - return api - .get(`project/${pid}/revenue/status`) - .then((response): RevenueStatus => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -export const getRevenueData = async ( - pid: string, - period: string, - from?: string, - to?: string, - timezone?: string, - timeBucket?: string, -): Promise<{ stats: RevenueStats; chart: RevenueChart }> => { - return api - .get('log/revenue', { - params: { pid, period, from, to, timezone, timeBucket }, - }) - .then((response): { stats: RevenueStats; chart: RevenueChart } => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) -} - -// Blog Sitemap (kept client-side for SitemapFunction - no request object available) -export const getSitemap = () => - api - .get('v1/blog/sitemap') - .then((response): (string | string[])[] => response.data) - .catch((error) => { - throw _isEmpty(error.response?.data?.message) ? error.response?.data : error.response?.data?.message - }) diff --git a/web/app/hooks/useAnalyticsProxy.ts b/web/app/hooks/useAnalyticsProxy.ts index 97b59364f..736562b7a 100644 --- a/web/app/hooks/useAnalyticsProxy.ts +++ b/web/app/hooks/useAnalyticsProxy.ts @@ -17,6 +17,19 @@ import type { ProfilesResponse, ProfileDetailsResponse, ProfileSessionsResponse, + Project, + VersionFilter, + CustomEventsMetadataResponse, + PropertyMetadataResponse, + ErrorSessionsResponse, + LiveStats, + LiveVisitorInfo, + ProjectDataCustomEventsResponse, + UserFlowResponse, + GSCKeywordsResponse, + RevenueStatus, + RevenueDataResponse, + OverallObject, } from '~/api/api.server' interface AnalyticsParams { @@ -462,7 +475,40 @@ export function useProfileSessionsProxy() { const [error, setError] = useState(null) const [isLoading, setIsLoading] = useState(false) - const fetchProfileSessions = useCallback(async (projectId: string, profileId: string, params: AnalyticsParams = {}) => { + const fetchProfileSessions = useCallback( + async (projectId: string, profileId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getProfileSessions', projectId, profileId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, + [], + ) + + return { fetchProfileSessions, data, error, isLoading } +} + +export function useProjectProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchProject = useCallback(async (projectId: string) => { setIsLoading(true) setError(null) @@ -470,9 +516,9 @@ export function useProfileSessionsProxy() { const response = await fetch('/api/analytics', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'getProfileSessions', projectId, profileId, params }), + body: JSON.stringify({ action: 'getProject', projectId, params: {} }), }) - const result = (await response.json()) as ProxyResponse + const result = (await response.json()) as ProxyResponse setData(result.data) setError(result.error) return result.data @@ -484,5 +530,342 @@ export function useProfileSessionsProxy() { } }, []) - return { fetchProfileSessions, data, error, isLoading } + return { fetchProject, data, error, isLoading } +} + +export function useFiltersProxy() { + const fetchFilters = useCallback(async (projectId: string, filterType: string): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getFilters', projectId, filterType, params: {} }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useFiltersProxy] Error:', err) + throw err + } + }, []) + + const fetchErrorsFilters = useCallback(async (projectId: string, filterType: string): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getErrorsFilters', projectId, filterType, params: {} }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useFiltersProxy] Error:', err) + throw err + } + }, []) + + const fetchVersionFilters = useCallback( + async ( + projectId: string, + dataType: 'traffic' | 'errors', + filterColumn: 'br' | 'os', + ): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getVersionFilters', projectId, dataType, filterColumn, params: {} }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useFiltersProxy] Error:', err) + throw err + } + }, + [], + ) + + return { fetchFilters, fetchErrorsFilters, fetchVersionFilters } +} + +export function useCustomEventsMetadataProxy() { + const fetchMetadata = useCallback( + async ( + projectId: string, + event: string, + params: AnalyticsParams = {}, + ): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getCustomEventsMetadata', projectId, event, params }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useCustomEventsMetadataProxy] Error:', err) + throw err + } + }, + [], + ) + + return { fetchMetadata } +} + +export function usePropertyMetadataProxy() { + const fetchMetadata = useCallback( + async ( + projectId: string, + property: string, + params: AnalyticsParams = {}, + ): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getPropertyMetadata', projectId, property, params }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[usePropertyMetadataProxy] Error:', err) + throw err + } + }, + [], + ) + + return { fetchMetadata } +} + +export function useErrorSessionsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchErrorSessions = useCallback(async (projectId: string, errorId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getErrorSessions', projectId, errorId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchErrorSessions, data, error, isLoading } +} + +export function useLiveVisitorsProxy() { + const fetchLiveVisitors = useCallback(async (pids: string[]): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getLiveVisitors', projectId: pids[0] || '', pids, params: {} }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useLiveVisitorsProxy] Error:', err) + throw err + } + }, []) + + const fetchLiveVisitorsInfo = useCallback(async (projectId: string): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getLiveVisitorsInfo', projectId, params: {} }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useLiveVisitorsProxy] Error:', err) + throw err + } + }, []) + + return { fetchLiveVisitors, fetchLiveVisitorsInfo } +} + +export function useProjectDataCustomEventsProxy() { + const fetchCustomEvents = useCallback( + async ( + projectId: string, + customEvents: string[], + params: AnalyticsParams = {}, + ): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getProjectDataCustomEvents', projectId, customEvents, params }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useProjectDataCustomEventsProxy] Error:', err) + throw err + } + }, + [], + ) + + return { fetchCustomEvents } +} + +export function useUserFlowProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchUserFlow = useCallback(async (projectId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getUserFlow', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchUserFlow, data, error, isLoading } +} + +export function useGSCKeywordsProxy() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchKeywords = useCallback(async (projectId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getGSCKeywords', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchKeywords, data, error, isLoading } +} + +export function useRevenueProxy() { + const [statusData, setStatusData] = useState(null) + const [revenueData, setRevenueData] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + + const fetchRevenueStatus = useCallback(async (projectId: string): Promise => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getRevenueStatus', projectId, params: {} }), + }) + const result = (await response.json()) as ProxyResponse + setStatusData(result.data) + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useRevenueProxy] Error:', err) + throw err + } + }, []) + + const fetchRevenueData = useCallback(async (projectId: string, params: AnalyticsParams = {}) => { + setIsLoading(true) + setError(null) + + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getRevenueData', projectId, params }), + }) + const result = (await response.json()) as ProxyResponse + setRevenueData(result.data) + setError(result.error) + return result.data + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + return null + } finally { + setIsLoading(false) + } + }, []) + + return { fetchRevenueStatus, fetchRevenueData, statusData, revenueData, error, isLoading } +} + +export function useOverallStatsProxy() { + const fetchOverallStats = useCallback( + async (pids: string[], params: AnalyticsParams = {}): Promise | null> => { + try { + const response = await fetch('/api/analytics', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getOverallStats', projectId: '', pids, params }), + }) + const result = (await response.json()) as ProxyResponse> + if (result.error) throw new Error(result.error) + return result.data + } catch (err) { + console.error('[useOverallStatsProxy] Error:', err) + return null + } + }, + [], + ) + + return { fetchOverallStats } } diff --git a/web/app/hooks/useAuthProxy.ts b/web/app/hooks/useAuthProxy.ts new file mode 100644 index 000000000..afb976c9b --- /dev/null +++ b/web/app/hooks/useAuthProxy.ts @@ -0,0 +1,121 @@ +import { useCallback } from 'react' + +import type { SSOProvider, SSOAuthURLResponse, SSOHashResponse, AuthMeResponse } from '~/api/api.server' + +interface ProxyResponse { + data: T | null + error: string | null +} + +export function useAuthProxy() { + const generateSSOAuthURL = useCallback( + async (provider: SSOProvider, redirectUrl?: string): Promise => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'generateSSOAuthURL', provider, redirectUrl }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error || !result.data) { + throw new Error(result.error || 'Failed to generate SSO URL') + } + return result.data + }, + [], + ) + + const getJWTBySSOHash = useCallback( + async (hash: string, provider: SSOProvider, remember = false): Promise => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'getJWTBySSOHash', hash, provider, remember }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error || !result.data) { + throw new Error(result.error || 'Failed to get JWT by SSO hash') + } + return result.data + }, + [], + ) + + const processSSOTokenCommunityEdition = useCallback( + async (code: string, hash: string, redirectUrl: string): Promise => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'processSSOTokenCommunityEdition', code, hash, redirectUrl }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) { + throw new Error(result.error) + } + return result.data + }, + [], + ) + + const linkBySSOHash = useCallback(async (hash: string, provider: SSOProvider): Promise => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'linkBySSOHash', hash, provider }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) { + throw new Error(result.error) + } + return result.data + }, []) + + const processSSOToken = useCallback(async (token: string, hash: string): Promise => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'processSSOToken', token, hash }), + }) + const result = (await response.json()) as ProxyResponse + if (result.error) { + throw new Error(result.error) + } + return result.data + }, []) + + const processGSCToken = useCallback(async (code: string, state: string): Promise<{ pid: string }> => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'processGSCToken', code, state }), + }) + const result = (await response.json()) as ProxyResponse<{ pid: string }> + if (result.error || !result.data) { + throw new Error(result.error || 'Failed to process GSC token') + } + return result.data + }, []) + + const authMe = useCallback(async (signal?: AbortSignal): Promise => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'authMe' }), + signal, + }) + const result = (await response.json()) as ProxyResponse + if (result.error || !result.data) { + throw new Error(result.error || 'Failed to fetch user') + } + return result.data + }, []) + + return { + generateSSOAuthURL, + getJWTBySSOHash, + processSSOTokenCommunityEdition, + linkBySSOHash, + processSSOToken, + processGSCToken, + authMe, + } +} diff --git a/web/app/lib/models/Project.ts b/web/app/lib/models/Project.ts index d764dcf08..32e5543cd 100644 --- a/web/app/lib/models/Project.ts +++ b/web/app/lib/models/Project.ts @@ -48,8 +48,6 @@ interface UserShareProject { id: string } -export type LiveStats = Record - export interface ShareOwnerProject { id: string confirmed: boolean diff --git a/web/app/pages/Auth/Signin/Signin.tsx b/web/app/pages/Auth/Signin/Signin.tsx index fee223899..84628fdb6 100644 --- a/web/app/pages/Auth/Signin/Signin.tsx +++ b/web/app/pages/Auth/Signin/Signin.tsx @@ -3,7 +3,7 @@ import { useTranslation, Trans } from 'react-i18next' import { Link, useNavigate, useSearchParams, Form, useActionData, useNavigation, useFetcher } from 'react-router' import { toast } from 'sonner' -import { generateSSOAuthURL, getJWTBySSOHash } from '~/api' +import { useAuthProxy } from '~/hooks/useAuthProxy' import GithubAuth from '~/components/GithubAuth' import GoogleAuth from '~/components/GoogleAuth' import OIDCAuth from '~/components/OIDCAuth' @@ -45,6 +45,7 @@ const Signin = () => { const [clearedErrors, setClearedErrors] = useState>(new Set()) const { setUser, setTotalMonthlyEvents, setIsAuthenticated } = useAuth() + const { generateSSOAuthURL, getJWTBySSOHash } = useAuthProxy() const isFormSubmitting = navigation.state === 'submitting' const is2FALoading = twoFAFetcher.state === 'submitting' @@ -148,7 +149,11 @@ const Signin = () => { await delay(HASH_CHECK_FREQUENCY) try { - const { accessToken, refreshToken, user, totalMonthlyEvents } = await getJWTBySSOHash(uuid, provider) + const { accessToken, refreshToken, user, totalMonthlyEvents } = await getJWTBySSOHash( + uuid, + provider, + !dontRemember, + ) authWindow.close() if (user.isTwoFactorAuthenticationEnabled) { @@ -163,7 +168,7 @@ const Signin = () => { setUser(user) setIsAuthenticated(true) setTotalMonthlyEvents(totalMonthlyEvents) - setAccessToken(accessToken, false) + setAccessToken(accessToken, !dontRemember) setRefreshToken(refreshToken) // Redirect to onboarding if user hasn't completed it diff --git a/web/app/pages/Auth/Signup/Signup.tsx b/web/app/pages/Auth/Signup/Signup.tsx index d5adb3f65..b7c4651e9 100644 --- a/web/app/pages/Auth/Signup/Signup.tsx +++ b/web/app/pages/Auth/Signup/Signup.tsx @@ -4,7 +4,7 @@ import { useTranslation, Trans } from 'react-i18next' import { Link, useNavigate, Form, useActionData, useNavigation } from 'react-router' import { toast } from 'sonner' -import { generateSSOAuthURL, getJWTBySSOHash } from '~/api' +import { useAuthProxy } from '~/hooks/useAuthProxy' import GithubAuth from '~/components/GithubAuth' import GoogleAuth from '~/components/GoogleAuth' import OIDCAuth from '~/components/OIDCAuth' @@ -45,6 +45,7 @@ const Signup = () => { const [clearedErrors, setClearedErrors] = useState>(new Set()) const { setUser, setTotalMonthlyEvents, setIsAuthenticated } = useAuth() + const { generateSSOAuthURL, getJWTBySSOHash } = useAuthProxy() const isFormSubmitting = navigation.state === 'submitting' const isLoading = isFormSubmitting || isSsoLoading @@ -129,7 +130,7 @@ const Signup = () => { await delay(HASH_CHECK_FREQUENCY) try { - const { accessToken, refreshToken, user, totalMonthlyEvents } = await getJWTBySSOHash(uuid, provider) + const { accessToken, refreshToken, user, totalMonthlyEvents } = await getJWTBySSOHash(uuid, provider, true) authWindow.close() if (user.isTwoFactorAuthenticationEnabled) { @@ -144,7 +145,7 @@ const Signup = () => { setUser(user) setIsAuthenticated(true) setTotalMonthlyEvents(totalMonthlyEvents) - setAccessToken(accessToken, false) + setAccessToken(accessToken, true) setRefreshToken(refreshToken) // Redirect to onboarding if user hasn't completed it diff --git a/web/app/pages/Auth/Socialised/index.tsx b/web/app/pages/Auth/Socialised/index.tsx index 8054b7b34..abe2dc6f3 100644 --- a/web/app/pages/Auth/Socialised/index.tsx +++ b/web/app/pages/Auth/Socialised/index.tsx @@ -3,7 +3,7 @@ import _split from 'lodash/split' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { processSSOToken, processSSOTokenCommunityEdition } from '~/api' +import { useAuthProxy } from '~/hooks/useAuthProxy' import { isSelfhosted, SSO_PROVIDERS } from '~/lib/constants' import StatusPage from '~/ui/StatusPage' import routes from '~/utils/routes' @@ -12,6 +12,7 @@ const Socialised = () => { const { t } = useTranslation('common') const [loading, setLoading] = useState(true) const [isError, setIsError] = useState(false) + const { processSSOToken, processSSOTokenCommunityEdition } = useAuthProxy() useEffect(() => { // For some reason, Google redirects to a hash URL, let's fix it diff --git a/web/app/pages/Dashboard/Dashboard.tsx b/web/app/pages/Dashboard/Dashboard.tsx index 5a60db7b1..7e5893aac 100644 --- a/web/app/pages/Dashboard/Dashboard.tsx +++ b/web/app/pages/Dashboard/Dashboard.tsx @@ -10,7 +10,7 @@ import { useLoaderData, useNavigate, useSearchParams, useFetcher, useNavigation, import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { getLiveVisitors, getOverallStats } from '~/api' +import { useLiveVisitorsProxy, useOverallStatsProxy } from '~/hooks/useAnalyticsProxy' import DashboardLockedBanner from '~/components/DashboardLockedBanner' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' import useBreakpoint from '~/hooks/useBreakpoint' @@ -89,6 +89,8 @@ const Dashboard = () => { const isLoading = navigation.state === 'loading' || revalidator.state === 'loading' const [liveStats, setLiveStats] = useState>({}) const [overallStats, setOverallStats] = useState({}) + const { fetchLiveVisitors } = useLiveVisitorsProxy() + const { fetchOverallStats } = useOverallStatsProxy() const activePeriod = useMemo(() => { return searchParams.get('period') || '7d' @@ -257,46 +259,43 @@ const Dashboard = () => { try { const projectIds = projects.map((p) => p.id) - const stats = await getLiveVisitors(projectIds) - setLiveStats(stats) + const stats = await fetchLiveVisitors(projectIds) + if (stats) { + setLiveStats(stats) + } } catch (reason) { console.error('Failed to fetch live visitors:', reason) } } - const updateOverallStats = async (projectIds: string[]) => { - if (!projectIds.length) return + updateLiveVisitors() + + const interval = setInterval(updateLiveVisitors, LIVE_VISITORS_UPDATE_INTERVAL) + + return () => clearInterval(interval) + }, [projects, fetchLiveVisitors]) // Reset interval when projects change + + // Fetch overall stats when projects or period changes + useEffect(() => { + const updateOverallStats = async () => { + if (!projects.length) return try { - const timeBucket = tbPeriodPairs(t).find((p) => p.period === activePeriod)?.tbs[0] || '' - const stats = await getOverallStats( - projectIds, - timeBucket, - activePeriod, - '', - '', - 'Etc/GMT', - '', - undefined, - true, - ) - setOverallStats((prev) => ({ ...prev, ...stats })) + const projectIds = projects.map((p) => p.id) + const stats = await fetchOverallStats(projectIds, { + period: activePeriod, + timezone: user?.timezone || 'UTC', + }) + if (stats) { + setOverallStats(stats) + } } catch (reason) { console.error('Failed to fetch overall stats:', reason) } } - const updateAllOverallStats = async () => { - await updateOverallStats(projects.map((p) => p.id)) - } - - updateLiveVisitors() - updateAllOverallStats() - - const interval = setInterval(updateLiveVisitors, LIVE_VISITORS_UPDATE_INTERVAL) - - return () => clearInterval(interval) - }, [projects, activePeriod, t]) // Reset interval when projects change + updateOverallStats() + }, [projects, activePeriod, user?.timezone, fetchOverallStats]) const onSearch = (e: React.ChangeEvent) => { setSearch(e.target.value) diff --git a/web/app/pages/Onboarding/Onboarding.tsx b/web/app/pages/Onboarding/Onboarding.tsx index 8a65c38c4..9d5ee1b45 100644 --- a/web/app/pages/Onboarding/Onboarding.tsx +++ b/web/app/pages/Onboarding/Onboarding.tsx @@ -7,8 +7,8 @@ import { Trans, useTranslation } from 'react-i18next' import { Link, useNavigate, useFetcher, useLoaderData } from 'react-router' import { toast } from 'sonner' -import { authMe } from '~/api' import { CONTACT_US_URL } from '~/components/Footer' +import { useAuthProxy } from '~/hooks/useAuthProxy' import { DOCS_URL, INTEGRATIONS_URL, isSelfhosted } from '~/lib/constants' import { getSnippet } from '~/modals/TrackingSnippet' import { useAuth } from '~/providers/AuthProvider' @@ -70,6 +70,7 @@ const Onboarding = () => { const { t } = useTranslation('common') const loaderData = useLoaderData() const { user, loadUser, logout } = useAuth() + const { authMe } = useAuthProxy() const navigate = useNavigate() const fetcher = useFetcher() @@ -163,7 +164,7 @@ const Onboarding = () => { const interval = setInterval(checkForEvents, 3000) return () => clearInterval(interval) - }, [isWaitingForEvents, project]) + }, [isWaitingForEvents, project, authMe]) const updateUserStep = (step: string) => { const formData = new FormData() diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index 6eeb9db65..81debfe0c 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -27,7 +27,7 @@ import { Trans, useTranslation } from 'react-i18next' import { useLoaderData, useNavigate, Link, useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' -import { getFilters, getProject } from '~/api' +import { useFiltersProxy, useProjectProxy } from '~/hooks/useAnalyticsProxy' import { useRequiredParams } from '~/hooks/useRequiredParams' import { isSelfhosted, TITLE_SUFFIX, FILTERS_PANELS_ORDER, isBrowser } from '~/lib/constants' import { Project } from '~/lib/models/Project' @@ -88,6 +88,7 @@ interface ModalMessageProps { setActiveFilter: any filterType: string setFilterType: (a: string) => void + fetchFilters: (projectId: string, filterType: string) => Promise } const ModalMessage = ({ @@ -100,6 +101,7 @@ const ModalMessage = ({ setActiveFilter, filterType, setFilterType, + fetchFilters, }: ModalMessageProps) => { const { t, @@ -110,9 +112,9 @@ const ModalMessage = ({ const getFiltersList = async () => { if (!_isEmpty(filterType)) { - const res = await getFilters(pid, filterType) - setFilterList(res) - setSearchList(res) + const res = await fetchFilters(pid, filterType) + setFilterList(res || []) + setSearchList(res || []) if (!_isEmpty(activeFilter)) { setActiveFilter([]) } @@ -269,6 +271,8 @@ const ProjectSettings = () => { const { requestOrigin } = useLoaderData<{ requestOrigin: string | null }>() const fetcher = useFetcher() const gscFetcher = useFetcher() + const { fetchProject } = useProjectProxy() + const { fetchFilters } = useFiltersProxy() const [project, setProject] = useState(null) const [form, setForm] = useState
({ @@ -428,17 +432,23 @@ const ProjectSettings = () => { setIsLoading(true) try { - const result = await getProject(projectId) - setProject(result) - setCaptchaSecretKey(result.captchaSecretKey) - setCaptchaDifficulty(result.captchaDifficulty || 4) - setForm({ - ...result, - ipBlacklist: _isString(result.ipBlacklist) ? result.ipBlacklist : _join(result.ipBlacklist, ', '), - origins: _isString(result.origins) ? result.origins : _join(result.origins, ', '), - countryBlacklist: result.countryBlacklist || [], - websiteUrl: result.websiteUrl || null, - }) + const result = await fetchProject(projectId) + if (result) { + setProject(result as unknown as Project) + setCaptchaSecretKey(result.captchaSecretKey || null) + setCaptchaDifficulty(4) + setForm({ + name: result.name, + id: result.id, + public: result.public, + isPasswordProtected: result.isPasswordProtected, + ipBlacklist: _isString(result.ipBlacklist) ? result.ipBlacklist : _join(result.ipBlacklist, ', '), + origins: _isString(result.origins) ? result.origins : _join(result.origins, ', '), + botsProtectionLevel: result.botsProtectionLevel as 'off' | 'basic', + gscPropertyUri: null, + websiteUrl: null, + }) + } } catch (reason: any) { setError(reason) } finally { @@ -754,8 +764,8 @@ const ProjectSettings = () => { const reloadProject = useCallback(async () => { try { - const result = await getProject(id) - setProject(result) + const result = await fetchProject(id) + if (result) setProject(result as unknown as Project) } catch (reason: any) { console.error(`[ERROR] Error while reloading project: ${reason}`) } @@ -1172,6 +1182,7 @@ const ProjectSettings = () => { setActiveFilter={setActiveFilter} filterType={filterType} setFilterType={setFilterType} + fetchFilters={fetchFilters} /> } submitType='danger' diff --git a/web/app/pages/Project/View/Panels.tsx b/web/app/pages/Project/View/Panels.tsx index c99c646e2..678097f7d 100644 --- a/web/app/pages/Project/View/Panels.tsx +++ b/web/app/pages/Project/View/Panels.tsx @@ -24,10 +24,10 @@ import React, { memo, useState, useEffect, useMemo, Fragment, useRef } from 'rea import { useTranslation } from 'react-i18next' import { Link, LinkProps, useNavigate } from 'react-router' -import { getProjectDataCustomEvents } from '~/api' +import { useProjectDataCustomEventsProxy } from '~/hooks/useAnalyticsProxy' import { PROJECT_TABS } from '~/lib/constants' import { Entry } from '~/lib/models/Entry' -import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import { useCurrentProject } from '~/providers/CurrentProjectProvider' import Button from '~/ui/Button' import Dropdown from '~/ui/Dropdown' import Sort from '~/ui/icons/Sort' @@ -397,8 +397,8 @@ function sortDesc(obj: T, sortByKeys?: boolean): T { const CustomEvents = ({ customs, chartData, filters, getCustomEventMetadata, getFilterLink }: CustomEventsProps) => { const { t } = useTranslation('common') const { id } = useCurrentProject() - const projectPassword = useProjectPassword(id) const { timeBucket, timezone, period, dateRange, timeFormat, rotateXAxis } = useViewProjectContext() + const { fetchCustomEvents } = useProjectDataCustomEventsProxy() const [detailsOpened, setDetailsOpened] = useState(false) const [activeEvents, setActiveEvents] = useState({}) @@ -467,17 +467,14 @@ const CustomEvents = ({ customs, chartData, filters, getCustomEventMetadata, get const to = isCustomPeriod ? getFormatDate(dateRange![1]) : '' const periodValue = isCustomPeriod ? '' : period - const data = await getProjectDataCustomEvents( - id, + const data = await fetchCustomEvents(id, topEventsForChart, { timeBucket, - periodValue, + period: periodValue, filters, from, to, timezone, - topEventsForChart, - projectPassword, - ) + }) if (cancelled) { return @@ -501,7 +498,7 @@ const CustomEvents = ({ customs, chartData, filters, getCustomEventMetadata, get return () => { cancelled = true } - }, [id, timeBucket, period, dateRange, timezone, projectPassword, filters, topEventsForChart]) + }, [id, timeBucket, period, dateRange, timezone, filters, topEventsForChart, fetchCustomEvents]) useEffect(() => { setEventsMetadata({}) diff --git a/web/app/pages/Project/View/components/AddAViewModal.tsx b/web/app/pages/Project/View/components/AddAViewModal.tsx index 259f4d037..7ff24b4de 100644 --- a/web/app/pages/Project/View/components/AddAViewModal.tsx +++ b/web/app/pages/Project/View/components/AddAViewModal.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { getFilters } from '~/api' +import { useFiltersProxy } from '~/hooks/useAnalyticsProxy' import { FILTERS_PANELS_ORDER } from '~/lib/constants' import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' import { ProjectViewActionData } from '~/routes/projects.$id' @@ -203,14 +203,15 @@ const AddAViewModal = ({ onSubmit, showModal, setShowModal, tnMapping, defaultVi const [activeFilters, setActiveFilters] = useState(defaultView?.filters || []) const [customEvents, setCustomEvents] = useState[]>(defaultView?.customEvents || []) const [errors, setErrors] = useState({}) + const { fetchFilters } = useFiltersProxy() const getFiltersList = useCallback( async (category: string) => { - const result = await getFilters(id, category, projectPassword) + const result = await fetchFilters(id, category) - setSearchList(result) + setSearchList(result || []) }, - [id, projectPassword], + [id, fetchFilters], ) useEffect(() => { diff --git a/web/app/pages/Project/View/components/LiveVisitorsDropdown.tsx b/web/app/pages/Project/View/components/LiveVisitorsDropdown.tsx index c2d47f174..95041376b 100644 --- a/web/app/pages/Project/View/components/LiveVisitorsDropdown.tsx +++ b/web/app/pages/Project/View/components/LiveVisitorsDropdown.tsx @@ -4,7 +4,8 @@ import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router' -import { getLiveVisitorsInfo, GetLiveVisitorsInfo } from '~/api' +import type { LiveVisitorInfo } from '~/api/api.server' +import { useLiveVisitorsProxy } from '~/hooks/useAnalyticsProxy' import { PROJECT_TABS } from '~/lib/constants' import Flag from '~/ui/Flag' import PulsatingCircle from '~/ui/icons/PulsatingCircle' @@ -12,23 +13,23 @@ import Spin from '~/ui/icons/Spin' import OutsideClickHandler from '~/ui/OutsideClickHandler' import { cn } from '~/utils/generic' -import { useCurrentProject, useProjectPassword } from '../../../../providers/CurrentProjectProvider' +import { useCurrentProject } from '../../../../providers/CurrentProjectProvider' const LiveVisitorsDropdown = () => { const { id, liveVisitors, updateLiveVisitors } = useCurrentProject() - const projectPassword = useProjectPassword(id) const { t } = useTranslation() const [isDropdownVisible, setIsDropdownVisible] = useState(false) const [isLoading, setIsLoading] = useState(true) - const [liveInfo, setLiveInfo] = useState([]) + const [liveInfo, setLiveInfo] = useState([]) + const { fetchLiveVisitorsInfo } = useLiveVisitorsProxy() const getLiveVisitors = async () => { setIsLoading(true) try { // Getting live sessions list and updating live visitors count to make sure it matches the list length - const [info] = await Promise.all([getLiveVisitorsInfo(id, projectPassword), updateLiveVisitors()]) - setLiveInfo(info) + const [info] = await Promise.all([fetchLiveVisitorsInfo(id), updateLiveVisitors()]) + setLiveInfo(info || []) } catch (reason) { console.error('[LiveVisitorsDropdown] getLiveVisitors:', reason) } diff --git a/web/app/pages/Project/View/components/SearchFilters.tsx b/web/app/pages/Project/View/components/SearchFilters.tsx index 3828e2ad9..c207a4848 100644 --- a/web/app/pages/Project/View/components/SearchFilters.tsx +++ b/web/app/pages/Project/View/components/SearchFilters.tsx @@ -6,7 +6,7 @@ import { useState, useEffect, useCallback, Fragment } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router' -import { getFilters, getErrorsFilters, getVersionFilters } from '~/api' +import { useFiltersProxy } from '~/hooks/useAnalyticsProxy' import { FILTERS_PANELS_ORDER, ERRORS_FILTERS_PANELS_ORDER } from '~/lib/constants' import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' import { useTheme } from '~/providers/ThemeProvider' @@ -119,6 +119,7 @@ const SearchFilters = ({ showModal, setShowModal, tnMapping, filters, type }: Se const [loadingColumns, setLoadingColumns] = useState>(new Set()) const [searchParams] = useSearchParams() const navigate = useNavigate() + const { fetchFilters, fetchErrorsFilters, fetchVersionFilters } = useFiltersProxy() const panelOptions = type === 'traffic' ? FILTERS_PANELS_ORDER : ERRORS_FILTERS_PANELS_ORDER @@ -128,22 +129,26 @@ const SearchFilters = ({ showModal, setShowModal, tnMapping, filters, type }: Se setLoadingColumns((prev) => new Set(prev).add(column)) try { - let result: string[] + let result: string[] = [] // For browser/OS versions, fetch valid combinations from the backend if (column === 'brv') { const dataType = type === 'errors' ? 'errors' : 'traffic' - const pairs = await getVersionFilters(id, dataType, 'br', projectPassword) - result = pairs.map((p) => createVersionValue(p.name, p.version)) + const pairs = await fetchVersionFilters(id, dataType, 'br') + if (pairs) { + result = pairs.map((p) => createVersionValue(p.name, p.version)) + } } else if (column === 'osv') { const dataType = type === 'errors' ? 'errors' : 'traffic' - const pairs = await getVersionFilters(id, dataType, 'os', projectPassword) - result = pairs.map((p) => createVersionValue(p.name, p.version)) + const pairs = await fetchVersionFilters(id, dataType, 'os') + if (pairs) { + result = pairs.map((p) => createVersionValue(p.name, p.version)) + } } else { if (type === 'errors') { - result = await getErrorsFilters(id, column, projectPassword) + result = (await fetchErrorsFilters(id, column)) || [] } else { - result = await getFilters(id, column, projectPassword) + result = (await fetchFilters(id, column)) || [] } } setFilterValuesCache((prev) => ({ ...prev, [column]: result })) @@ -158,7 +163,7 @@ const SearchFilters = ({ showModal, setShowModal, tnMapping, filters, type }: Se }) } }, - [id, projectPassword, type, filterValuesCache, loadingColumns], + [id, type, filterValuesCache, loadingColumns, fetchFilters, fetchErrorsFilters, fetchVersionFilters], ) // Pre-fetch all filter values when modal opens diff --git a/web/app/pages/Project/tabs/Errors/ErrorDetails.tsx b/web/app/pages/Project/tabs/Errors/ErrorDetails.tsx index d6c4e2272..8c5ba37bc 100644 --- a/web/app/pages/Project/tabs/Errors/ErrorDetails.tsx +++ b/web/app/pages/Project/tabs/Errors/ErrorDetails.tsx @@ -5,7 +5,8 @@ import { useMemo, useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Link, useLocation } from 'react-router' -import { getErrorSessions, type ErrorAffectedSession } from '~/api' +import type { ErrorAffectedSession } from '~/api/api.server' +import { useErrorSessionsProxy } from '~/hooks/useAnalyticsProxy' import { BROWSER_LOGO_MAP, OS_LOGO_MAP, OS_LOGO_MAP_DARK } from '~/lib/constants' import { SwetrixErrorDetails } from '~/lib/models/Project' import { useCurrentProject } from '~/providers/CurrentProjectProvider' @@ -120,13 +121,13 @@ export const ErrorDetails = ({ from = '', to = '', timeBucket = 'hour', - projectPassword, }: ErrorDetailsProps) => { const { t, i18n: { language }, } = useTranslation('common') const { id } = useCurrentProject() + const { fetchErrorSessions } = useErrorSessionsProxy() const [isStackTraceExpanded, setIsStackTraceExpanded] = useState(false) const [isCopiedStack, setIsCopiedStack] = useState(false) @@ -230,27 +231,26 @@ export const ErrorDetails = ({ const skip = reset ? 0 : sessionsSkip try { - const result = await getErrorSessions( - id, - details.eid, + const result = await fetchErrorSessions(id, details.eid, { timeBucket, period, from, to, - SESSIONS_TAKE, + take: SESSIONS_TAKE, skip, - projectPassword, - ) + }) if (!isMountedRef.current) return - if (reset) { - setSessions(result.sessions) - } else { - setSessions((prev) => [...prev, ...result.sessions]) + if (result) { + if (reset) { + setSessions(result.sessions) + } else { + setSessions((prev) => [...prev, ...result.sessions]) + } + setSessionsTotal(result.total) + setSessionsSkip(skip + SESSIONS_TAKE) } - setSessionsTotal(result.total) - setSessionsSkip(skip + SESSIONS_TAKE) } catch (reason) { console.error('[ErrorDetails] Failed to load sessions:', reason) } finally { @@ -259,7 +259,7 @@ export const ErrorDetails = ({ } } }, - [id, details.eid, timeBucket, period, from, to, projectPassword, sessionsLoading, sessionsSkip], + [id, details.eid, timeBucket, period, from, to, sessionsLoading, sessionsSkip, fetchErrorSessions], ) useEffect(() => { diff --git a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx index f895be46a..cd81edac7 100644 --- a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx +++ b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx @@ -18,7 +18,8 @@ import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { getFilters, type ProjectFeatureFlag, type TargetingRule } from '~/api' +import type { ProjectFeatureFlag, TargetingRule } from '~/api' +import { useFiltersProxy } from '~/hooks/useAnalyticsProxy' import { API_URL, isSelfhosted } from '~/lib/constants' import { useTheme } from '~/providers/ThemeProvider' import type { ProjectViewActionData } from '~/routes/projects.$id' @@ -61,6 +62,7 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI const isNew = !flagId const fetcher = useFetcher() const processedRef = useRef(null) + const { fetchFilters } = useFiltersProxy() const [isLoading, setIsLoading] = useState(false) const [showImplementation, setShowImplementation] = useState(false) @@ -96,8 +98,8 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI setLoadingColumns((prev) => new Set(prev).add(column)) try { - const result = await getFilters(projectId, column) - setFilterValuesCache((prev) => ({ ...prev, [column]: result })) + const result = await fetchFilters(projectId, column) + setFilterValuesCache((prev) => ({ ...prev, [column]: result || [] })) } catch (error) { console.error('Failed to fetch filter values:', error) setFilterValuesCache((prev) => ({ ...prev, [column]: [] })) @@ -109,7 +111,7 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI }) } }, - [projectId, filterValuesCache, loadingColumns], + [projectId, filterValuesCache, loadingColumns, fetchFilters], ) // Pre-fetch filter values when modal opens diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index 3993f45c0..3c8442e05 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -13,12 +13,11 @@ import { useNavigate, Link, useSearchParams, useLoaderData, useRevalidator } fro import { toast } from 'sonner' import { - getCustomEventsMetadata, - getPropertyMetadata, - getGSCKeywords, - getRevenueData, - getRevenueStatus, -} from '~/api' + useCustomEventsMetadataProxy, + usePropertyMetadataProxy, + useGSCKeywordsProxy, + useRevenueProxy, +} from '~/hooks/useAnalyticsProxy' import type { TrafficLogResponse as ServerTrafficLogResponse, OverallObject as ServerOverallObject, @@ -156,6 +155,10 @@ const TrafficViewInner = ({ const { id, project, allowedToManage } = useCurrentProject() const projectPassword = useProjectPassword(id) const revalidator = useRevalidator() + const { fetchMetadata: fetchCustomEventsMetadata } = useCustomEventsMetadataProxy() + const { fetchMetadata: fetchPropertyMetadata } = usePropertyMetadataProxy() + const { fetchKeywords: fetchGSCKeywords } = useGSCKeywordsProxy() + const { fetchRevenueStatus, fetchRevenueData } = useRevenueProxy() const { trafficRefreshTrigger, timezone, @@ -319,14 +322,14 @@ const TrafficViewInner = ({ // Fetch revenue status useEffect(() => { - const fetchRevenueStatus = async () => { + const loadRevenueStatus = async () => { if (!id || isSelfhosted) { return } try { - const status = await getRevenueStatus(id) - if (isMountedRef.current) { + const status = await fetchRevenueStatus(id) + if (isMountedRef.current && status) { setIsRevenueConnected(status.connected) } } catch (error) { @@ -334,8 +337,8 @@ const TrafficViewInner = ({ } } - fetchRevenueStatus() - }, [id]) + loadRevenueStatus() + }, [id, fetchRevenueStatus]) // Version data mapping for browser/OS versions const createVersionDataMapping = useMemo(() => { @@ -540,14 +543,13 @@ const TrafficViewInner = ({ to = getFormatDate(dateRange[1]) } - const revResult = await getRevenueData( - id, - period === 'custom' ? 'custom' : period, + const revResult = await fetchRevenueData(id, { + period: period === 'custom' ? 'custom' : period, from, to, timezone, timeBucket, - ) + }) const revChart = revResult?.chart const revX = revChart?.x || [] const revY = revChart?.revenue || [] @@ -591,38 +593,38 @@ const TrafficViewInner = ({ ]) const getCustomEventMetadata = async (event: string) => { + let result if (period === 'custom' && dateRange) { - return getCustomEventsMetadata( - id, - event, + result = await fetchCustomEventsMetadata(id, event, { timeBucket, - '', - getFormatDate(dateRange[0]), - getFormatDate(dateRange[1]), + period: '', + from: getFormatDate(dateRange[0]), + to: getFormatDate(dateRange[1]), timezone, - projectPassword, - ) + }) + } else { + result = await fetchCustomEventsMetadata(id, event, { timeBucket, period, timezone }) } - return getCustomEventsMetadata(id, event, timeBucket, period, '', '', timezone, projectPassword) + return result || { result: [] } } const _getPropertyMetadata = async (property: string) => { + let result if (period === 'custom' && dateRange) { - return getPropertyMetadata( - id, - property, + result = await fetchPropertyMetadata(id, property, { timeBucket, - '', - getFormatDate(dateRange[0]), - getFormatDate(dateRange[1]), + period: '', + from: getFormatDate(dateRange[0]), + to: getFormatDate(dateRange[1]), filters, timezone, - projectPassword, - ) + }) + } else { + result = await fetchPropertyMetadata(id, property, { timeBucket, period, filters, timezone }) } - return getPropertyMetadata(id, property, timeBucket, period, '', '', filters, timezone, projectPassword) + return result || { result: [] } } // Load GSC Keywords when the traffic sources panel switches to 'keywords' @@ -647,10 +649,10 @@ const TrafficViewInner = ({ const data = period === 'custom' && dateRange - ? await getGSCKeywords(id, '', from, to, timezone, projectPassword) - : await getGSCKeywords(id, period, '', '', timezone, projectPassword) + ? await fetchGSCKeywords(id, { period: '', from, to, timezone }) + : await fetchGSCKeywords(id, { period, timezone }) - if (isMountedRef.current) { + if (isMountedRef.current && data) { if (data.notConnected) { setKeywordsNotConnected(true) setKeywords([]) diff --git a/web/app/pages/Project/tabs/Traffic/UserFlow.tsx b/web/app/pages/Project/tabs/Traffic/UserFlow.tsx index 99b7ea56d..b5ef771d2 100644 --- a/web/app/pages/Project/tabs/Traffic/UserFlow.tsx +++ b/web/app/pages/Project/tabs/Traffic/UserFlow.tsx @@ -4,11 +4,11 @@ import { useEffect, useState, memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { getUserFlow } from '~/api' +import { useUserFlowProxy } from '~/hooks/useAnalyticsProxy' import { UserFlowType } from '~/lib/models/UserFlow' import Loader from '~/ui/Loader' -import { useCurrentProject, useProjectPassword } from '../../../../providers/CurrentProjectProvider' +import { useCurrentProject } from '../../../../providers/CurrentProjectProvider' import { useViewProjectContext } from '../../View/ViewProject' import { getFormatDate } from '../../View/ViewProject.helpers' @@ -20,13 +20,13 @@ interface UserFlowProps { const UserFlow = ({ setReversed, isReversed }: UserFlowProps) => { const { dateRange, period, timeBucket, timezone, filters } = useViewProjectContext() const { id } = useCurrentProject() - const projectPassword = useProjectPassword(id) const { t } = useTranslation('common') const [isLoading, setIsLoading] = useState(null) const [userFlow, setUserFlow] = useState<{ ascending: UserFlowType descending: UserFlowType } | null>(null) + const { fetchUserFlow: fetchUserFlowProxy } = useUserFlowProxy() const [from, to] = useMemo(() => { if (!dateRange) { @@ -44,17 +44,20 @@ const UserFlow = ({ setReversed, isReversed }: UserFlowProps) => { setIsLoading(true) try { - const { ascending, descending } = await getUserFlow( - id, + const result = await fetchUserFlowProxy(id, { timeBucket, period, filters, from, to, timezone, - projectPassword, - ) - setUserFlow({ ascending, descending }) + }) + if (result) { + setUserFlow({ + ascending: result as unknown as UserFlowType, + descending: result as unknown as UserFlowType, + }) + } } catch (error: any) { toast.error(typeof error === 'string' ? error : t('apiNotifications.somethingWentWrong')) } finally { diff --git a/web/app/pages/UserSettings/components/Socialisations.tsx b/web/app/pages/UserSettings/components/Socialisations.tsx index 0e24ab7dd..a6070668d 100644 --- a/web/app/pages/UserSettings/components/Socialisations.tsx +++ b/web/app/pages/UserSettings/components/Socialisations.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' -import { generateSSOAuthURL, linkBySSOHash } from '~/api' +import { useAuthProxy } from '~/hooks/useAuthProxy' import { SSO_PROVIDERS } from '~/lib/constants' import { SSOProvider } from '~/lib/models/Auth' import { User } from '~/lib/models/User' @@ -67,6 +67,7 @@ const Socialisations = () => { const [isLoading, setIsLoading] = useState(false) const fetcher = useFetcher() const isUnlinking = fetcher.state !== 'idle' + const { generateSSOAuthURL, linkBySSOHash } = useAuthProxy() useEffect(() => { if (fetcher.data?.intent === 'unlink-sso') { diff --git a/web/app/providers/AuthProvider.tsx b/web/app/providers/AuthProvider.tsx index 32525f0f0..035634e71 100644 --- a/web/app/providers/AuthProvider.tsx +++ b/web/app/providers/AuthProvider.tsx @@ -1,7 +1,6 @@ -import { isCancel } from 'axios' import { createContext, useCallback, useContext, useEffect, useState } from 'react' -import { authMe } from '~/api' +import { useAuthProxy } from '~/hooks/useAuthProxy' import { User } from '~/lib/models/User' import { getAccessToken } from '~/utils/accessToken' import { clearLocalStorageOnLogout } from '~/utils/auth' @@ -35,6 +34,8 @@ export const AuthProvider = ({ initialUser, initialTotalMonthlyEvents, }: AuthProviderProps) => { + const { authMe } = useAuthProxy() + // If we have initial user from loader, we're definitely authenticated const [isAuthenticated, setIsAuthenticated] = useState(initialIsAuthenticated || !!initialUser) @@ -83,12 +84,12 @@ export const AuthProvider = ({ const loadUser = useCallback( async (signal?: AbortSignal) => { try { - const { user, totalMonthlyEvents } = await authMe({ signal }) + const { user, totalMonthlyEvents } = await authMe(signal) setUser(user) setTotalMonthlyEvents(totalMonthlyEvents) setIsAuthenticated(true) } catch (error) { - if (isCancel(error)) { + if (error instanceof Error && error.name === 'AbortError') { return } @@ -98,7 +99,7 @@ export const AuthProvider = ({ setIsLoading(false) } }, - [logout], + [logout, authMe], ) useEffect(() => { diff --git a/web/app/providers/CurrentProjectProvider.tsx b/web/app/providers/CurrentProjectProvider.tsx index 88050b3a2..98ea3bd38 100644 --- a/web/app/providers/CurrentProjectProvider.tsx +++ b/web/app/providers/CurrentProjectProvider.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams, useFetcher, useLoaderData } from 'react-router' import { toast } from 'sonner' -import { getLiveVisitors } from '~/api' +import { useLiveVisitorsProxy } from '~/hooks/useAnalyticsProxy' import { LIVE_VISITORS_UPDATE_INTERVAL, LS_PROJECTS_PROTECTED_KEY, Period, TimeBucket } from '~/lib/constants' import { type Project } from '~/lib/models/Project' import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' @@ -152,17 +152,19 @@ const useProjectPreferences = (id: string) => { const useLiveVisitors = (project: Project | null) => { const projectId = project?.id const isLocked = project?.isLocked - const projectPassword = useProjectPassword(projectId) const [liveVisitors, setLiveVisitors] = useState(0) + const { fetchLiveVisitors } = useLiveVisitorsProxy() const updateLiveVisitors = useCallback(async () => { if (!projectId || isLocked) { return } - const result = await getLiveVisitors([projectId], projectPassword) - setLiveVisitors(result[projectId] || 0) - }, [projectId, isLocked, projectPassword]) + const result = await fetchLiveVisitors([projectId]) + if (result) { + setLiveVisitors(result[projectId] || 0) + } + }, [projectId, isLocked, fetchLiveVisitors]) useEffect(() => { if (!projectId || isLocked) { diff --git a/web/app/routes/api.ai.ts b/web/app/routes/api.ai.ts new file mode 100644 index 000000000..9bef5a8be --- /dev/null +++ b/web/app/routes/api.ai.ts @@ -0,0 +1,40 @@ +import { type ActionFunctionArgs, type LoaderFunctionArgs, data } from 'react-router' + +import { streamingServerFetch } from '~/api/api.server' + +export async function action({ request }: ActionFunctionArgs) { + const body = await request.json() + const { pid, messages, timezone } = body + + if (!pid || !messages) { + return data({ error: 'pid and messages are required' }, { status: 400 }) + } + + try { + const response = await streamingServerFetch(request, `ai/${pid}/chat`, { + method: 'POST', + body: { messages, timezone }, + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return data({ error: errorData.message || `HTTP error! status: ${response.status}` }, { status: response.status }) + } + + // Stream the response back to the client + return new Response(response.body, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) + } catch (error) { + console.error('[api.ai] Proxy request failed:', error) + return data({ error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 }) + } +} + +export async function loader({ request }: LoaderFunctionArgs) { + return data({ error: 'Use POST method' }, { status: 405 }) +} diff --git a/web/app/routes/api.analytics.ts b/web/app/routes/api.analytics.ts index c8627936e..cf21c91e0 100644 --- a/web/app/routes/api.analytics.ts +++ b/web/app/routes/api.analytics.ts @@ -16,6 +16,21 @@ import { getProfilesServer, getProfileServer, getProfileSessionsServer, + getProjectServer, + getFiltersServer, + getErrorsFiltersServer, + getVersionFiltersServer, + getCustomEventsMetadataServer, + getPropertyMetadataServer, + getErrorSessionsServer, + getLiveVisitorsServer, + getLiveVisitorsInfoServer, + getProjectDataCustomEventsServer, + getUserFlowServer, + getGSCKeywordsServer, + getRevenueStatusServer, + getRevenueDataServer, + getOverallStatsServer, type AnalyticsParams, type AnalyticsFilter, type SessionsResponse, @@ -33,6 +48,19 @@ import { type ProfilesResponse, type ProfileDetailsResponse, type ProfileSessionsResponse, + type Project, + type VersionFilter, + type CustomEventsMetadataResponse, + type PropertyMetadataResponse, + type ErrorSessionsResponse, + type LiveStats, + type LiveVisitorInfo, + type ProjectDataCustomEventsResponse, + type UserFlowResponse, + type GSCKeywordsResponse, + type RevenueStatus, + type RevenueDataResponse, + type OverallObject, } from '~/api/api.server' import { getProjectPasswordCookie } from '~/utils/session.server' @@ -53,11 +81,34 @@ interface ProxyRequest { | 'getProfiles' | 'getProfile' | 'getProfileSessions' + | 'getProject' + | 'getFilters' + | 'getErrorsFilters' + | 'getVersionFilters' + | 'getCustomEventsMetadata' + | 'getPropertyMetadata' + | 'getErrorSessions' + | 'getLiveVisitors' + | 'getLiveVisitorsInfo' + | 'getProjectDataCustomEvents' + | 'getUserFlow' + | 'getGSCKeywords' + | 'getRevenueStatus' + | 'getRevenueData' + | 'getOverallStats' projectId: string + pids?: string[] flagId?: string goalId?: string experimentId?: string profileId?: string + errorId?: string + filterType?: string + filterColumn?: 'br' | 'os' + dataType?: 'traffic' | 'errors' + event?: string + property?: string + customEvents?: string[] params: { timeBucket?: string period?: string @@ -82,7 +133,7 @@ interface ProxyResponse { export async function action({ request }: ActionFunctionArgs) { const body = (await request.json()) as ProxyRequest - const { action, projectId, params } = body + const { action, projectId, pids, params } = body const password = getProjectPasswordCookie(request, projectId) @@ -342,6 +393,217 @@ export async function action({ request }: ActionFunctionArgs) { }) } + case 'getProject': { + const result = await getProjectServer(request, projectId, password || undefined) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getFilters': { + if (!body.filterType) { + return data>({ data: null, error: 'filterType is required' }, { status: 400 }) + } + const result = await getFiltersServer(request, projectId, body.filterType, password || undefined) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getErrorsFilters': { + if (!body.filterType) { + return data>({ data: null, error: 'filterType is required' }, { status: 400 }) + } + const result = await getErrorsFiltersServer(request, projectId, body.filterType, password || undefined) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getVersionFilters': { + if (!body.filterColumn || !body.dataType) { + return data>( + { data: null, error: 'filterColumn and dataType are required' }, + { status: 400 }, + ) + } + const result = await getVersionFiltersServer( + request, + projectId, + body.dataType, + body.filterColumn, + password || undefined, + ) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getCustomEventsMetadata': { + if (!body.event) { + return data>({ data: null, error: 'event is required' }, { status: 400 }) + } + const result = await getCustomEventsMetadataServer(request, projectId, body.event, { + timeBucket: params.timeBucket, + period: params.period, + from: params.from, + to: params.to, + timezone: params.timezone, + password: password || undefined, + }) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getPropertyMetadata': { + if (!body.property) { + return data>({ data: null, error: 'property is required' }, { status: 400 }) + } + const result = await getPropertyMetadataServer(request, projectId, body.property, { + timeBucket: params.timeBucket, + period: params.period, + from: params.from, + to: params.to, + filters: params.filters, + timezone: params.timezone, + password: password || undefined, + }) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getErrorSessions': { + if (!body.errorId) { + return data>({ data: null, error: 'errorId is required' }, { status: 400 }) + } + const result = await getErrorSessionsServer(request, projectId, body.errorId, { + timeBucket: params.timeBucket, + period: params.period, + from: params.from, + to: params.to, + take: params.take, + skip: params.skip, + password: password || undefined, + }) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getLiveVisitors': { + const pids = body.pids || [projectId] + const result = await getLiveVisitorsServer(request, pids, password || undefined) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getLiveVisitorsInfo': { + const result = await getLiveVisitorsInfoServer(request, projectId, password || undefined) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getProjectDataCustomEvents': { + const result = await getProjectDataCustomEventsServer(request, projectId, { + timeBucket: params.timeBucket, + period: params.period, + filters: params.filters, + from: params.from, + to: params.to, + timezone: params.timezone, + customEvents: body.customEvents, + password: password || undefined, + }) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getUserFlow': { + const result = await getUserFlowServer(request, projectId, { + timeBucket: params.timeBucket, + period: params.period, + filters: params.filters, + from: params.from, + to: params.to, + timezone: params.timezone, + password: password || undefined, + }) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getGSCKeywords': { + const result = await getGSCKeywordsServer(request, projectId, { + period: params.period, + from: params.from, + to: params.to, + timezone: params.timezone, + password: password || undefined, + }) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getRevenueStatus': { + const result = await getRevenueStatusServer(request, projectId) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getRevenueData': { + const result = await getRevenueDataServer(request, projectId, { + period: params.period || '7d', + from: params.from, + to: params.to, + timezone: params.timezone, + timeBucket: params.timeBucket, + }) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getOverallStats': { + if (!pids || pids.length === 0) { + return data>({ data: null, error: 'pids is required' }, { status: 400 }) + } + const result = await getOverallStatsServer(request, pids, { + timeBucket: params.timeBucket || 'day', + period: params.period || '7d', + from: params.from, + to: params.to, + timezone: params.timezone || 'UTC', + filters: params.filters, + includeChart: true, + }) + return data>>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + default: return data>({ data: null, error: `Unknown action: ${action}` }, { status: 400 }) } diff --git a/web/app/routes/api.auth.ts b/web/app/routes/api.auth.ts new file mode 100644 index 000000000..5fe55b6ff --- /dev/null +++ b/web/app/routes/api.auth.ts @@ -0,0 +1,154 @@ +import { type ActionFunctionArgs, type LoaderFunctionArgs, data } from 'react-router' + +import { + generateSSOAuthURLServer, + getJWTBySSOHashServer, + processSSOTokenCommunityEditionServer, + linkBySSOHashServer, + processSSOTokenServer, + processGSCTokenServer, + authMeServer, + type SSOProvider, + type SSOAuthURLResponse, + type SSOHashResponse, + type AuthMeResponse, +} from '~/api/api.server' +import { createAuthCookies } from '~/utils/session.server' + +interface ProxyRequest { + action: + | 'generateSSOAuthURL' + | 'getJWTBySSOHash' + | 'processSSOTokenCommunityEdition' + | 'linkBySSOHash' + | 'processSSOToken' + | 'processGSCToken' + | 'authMe' + provider?: SSOProvider + redirectUrl?: string + hash?: string + token?: string + code?: string + state?: string + remember?: boolean +} + +interface ProxyResponse { + data: T | null + error: string | null +} + +export async function action({ request }: ActionFunctionArgs) { + const body = (await request.json()) as ProxyRequest + const { action } = body + + try { + switch (action) { + case 'generateSSOAuthURL': { + if (!body.provider) { + return data>({ data: null, error: 'provider is required' }, { status: 400 }) + } + const result = await generateSSOAuthURLServer(request, body.provider, body.redirectUrl) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'getJWTBySSOHash': { + if (!body.hash || !body.provider) { + return data>({ data: null, error: 'hash and provider are required' }, { status: 400 }) + } + const result = await getJWTBySSOHashServer(request, body.hash, body.provider) + + if (result.data) { + const { accessToken, refreshToken, user } = result.data + const cookies = createAuthCookies({ accessToken, refreshToken }, body.remember ?? false) + + return data>( + { + data: result.data, + error: null, + }, + { + headers: cookies.map((cookie) => ['Set-Cookie', cookie]), + }, + ) + } + + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'processSSOTokenCommunityEdition': { + if (!body.code || !body.hash || !body.redirectUrl) { + return data>( + { data: null, error: 'code, hash, and redirectUrl are required' }, + { status: 400 }, + ) + } + const result = await processSSOTokenCommunityEditionServer(request, body.code, body.hash, body.redirectUrl) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'linkBySSOHash': { + if (!body.hash || !body.provider) { + return data>({ data: null, error: 'hash and provider are required' }, { status: 400 }) + } + const result = await linkBySSOHashServer(request, body.hash, body.provider) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'processSSOToken': { + if (!body.token || !body.hash) { + return data>({ data: null, error: 'token and hash are required' }, { status: 400 }) + } + const result = await processSSOTokenServer(request, body.token, body.hash) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'processGSCToken': { + if (!body.code || !body.state) { + return data>({ data: null, error: 'code and state are required' }, { status: 400 }) + } + const result = await processGSCTokenServer(request, body.code, body.state) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + case 'authMe': { + const result = await authMeServer(request) + return data>({ + data: result.data, + error: result.error ? (Array.isArray(result.error) ? result.error.join(', ') : result.error) : null, + }) + } + + default: + return data>({ data: null, error: `Unknown action: ${action}` }, { status: 400 }) + } + } catch (error) { + console.error('[api.auth] Proxy request failed:', error) + return data>( + { data: null, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 }, + ) + } +} + +export async function loader({ request }: LoaderFunctionArgs) { + return data({ error: 'Use POST method' }, { status: 405 }) +} diff --git a/web/app/routes/blog.$slug.tsx b/web/app/routes/blog.$slug.tsx index 5df20f39b..e30b9204e 100644 --- a/web/app/routes/blog.$slug.tsx +++ b/web/app/routes/blog.$slug.tsx @@ -5,8 +5,7 @@ import { redirect, data } from 'react-router' import type { LoaderFunctionArgs, MetaFunction } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' -import { getSitemap as getSitemapClient } from '~/api' -import { isSelfhosted, getOgImageUrl, isDisableMarketingPages } from '~/lib/constants' +import { isSelfhosted, getOgImageUrl, isDisableMarketingPages, API_URL } from '~/lib/constants' import Post from '~/pages/Blog/Post' import { getSlugFromFilename, getDateFromFilename } from '~/utils/blog' import { getPost } from '~/utils/getPosts.server' @@ -22,8 +21,10 @@ export const meta: MetaFunction = (loaderData: any) => { ] } +// @ts-expect-error export const sitemap: SitemapFunction = async () => { - const files = await getSitemapClient() + const response = await fetch(`${API_URL}v1/blog/sitemap`) + const files = await response.json() if (isSelfhosted || isDisableMarketingPages) { return { diff --git a/web/app/routes/gsc-connected.tsx b/web/app/routes/gsc-connected.tsx index 962517133..06162b558 100644 --- a/web/app/routes/gsc-connected.tsx +++ b/web/app/routes/gsc-connected.tsx @@ -4,7 +4,7 @@ import type { LoaderFunctionArgs } from 'react-router' import { redirect, useLoaderData, useNavigate } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' -import { processGSCToken } from '~/api' +import { useAuthProxy } from '~/hooks/useAuthProxy' import { serverFetch } from '~/api/api.server' import { isSelfhosted } from '~/lib/constants' import StatusPage from '~/ui/StatusPage' @@ -80,6 +80,7 @@ export default function GscConnectedRoute() { function GscHashHandler() { const navigate = useNavigate() const [error, setError] = useState(null) + const { processGSCToken } = useAuthProxy() const [{ state, code }] = useState(() => { if (typeof window === 'undefined') return { state: null, code: null } // Fix Google's hash URL redirect (sometimes Google uses # instead of ?) @@ -113,7 +114,7 @@ function GscHashHandler() { setError(String(reason)) console.error(`[ERROR] Error while processing GSC integration: ${reason}`) }) - }, [navigate, state, code]) + }, [navigate, state, code, processGSCToken]) if (!state || !code) { return ( diff --git a/web/app/utils/compareConvertDate.ts b/web/app/utils/compareConvertDate.ts deleted file mode 100644 index c5c737241..000000000 --- a/web/app/utils/compareConvertDate.ts +++ /dev/null @@ -1,97 +0,0 @@ -import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' - -import { getFormatDate } from '~/pages/Project/View/ViewProject.helpers' - -dayjs.extend(utc) - -export const periodToCompareDate: { - period: string - formula: (date?: Date[]) => { - from: string - to: string - } -}[] = [ - { - period: '1d', - formula: () => { - const to = dayjs.utc().subtract(1, 'day').format('YYYY-MM-DD') - const from = dayjs.utc().subtract(2, 'day').format('YYYY-MM-DD') - - return { - from, - to, - } - }, - }, - { - period: '7d', - formula: () => { - const to = dayjs.utc().subtract(7, 'day').format('YYYY-MM-DD') - const from = dayjs.utc().subtract(14, 'day').format('YYYY-MM-DD') - - return { - from, - to, - } - }, - }, - { - period: '4w', - formula: () => { - const to = dayjs.utc().subtract(28, 'day').format('YYYY-MM-DD') - const from = dayjs.utc().subtract(56, 'day').format('YYYY-MM-DD') - - return { - from, - to, - } - }, - }, - { - period: '3M', - formula: () => { - const to = dayjs.utc().subtract(3, 'month').format('YYYY-MM-DD') - const from = dayjs.utc().subtract(6, 'month').format('YYYY-MM-DD') - - return { - from, - to, - } - }, - }, - { - period: '12M', - formula: () => { - const to = dayjs.utc().subtract(12, 'month').format('YYYY-MM-DD') - const from = dayjs.utc().subtract(24, 'month').format('YYYY-MM-DD') - - return { - from, - to, - } - }, - }, - { - period: 'custom', - formula: (date) => { - if (!date) { - return { - from: getFormatDate(new Date()), - to: getFormatDate(new Date()), - } - } - const days = (date[1].getTime() - date[0].getTime()) / (1000 * 3600 * 24) - const to = dayjs.utc().subtract(days, 'day').format('YYYY-MM-DD') - const from = dayjs - .utc() - .subtract(days * 2, 'day') - .format('YYYY-MM-DD') - - return { - from, - to, - } - }, - }, -] diff --git a/web/app/utils/cookie.ts b/web/app/utils/cookie.ts index 2405ee285..2e1726a1f 100644 --- a/web/app/utils/cookie.ts +++ b/web/app/utils/cookie.ts @@ -31,12 +31,3 @@ export const setCookie = (key: string, value: string | number | boolean, maxAge document.cookie = generateCookieString(key, value, maxAge, sameSite) } - -export const deleteCookie = (key: string) => { - if (!isBrowser) { - return null - } - - document.cookie = `${key}=; max-age=0; path=/; SameSite=strict` - document.cookie = `${key}=; max-age=0; path=/; SameSite=strict${COOKIE_SUFFIX}` -} From 1b5cae7340ef96051661beb0de2aaf74ef7494d5 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 03:58:17 +0000 Subject: [PATCH 26/57] migrate the rest --- web/app/api/api.server.ts | 35 --------- web/app/api/index.ts | 72 ------------------- .../pages/Project/tabs/AskAI/AskAIView.tsx | 31 ++++++-- .../tabs/Experiments/ExperimentsView.tsx | 43 ++++++++++- .../FeatureFlags/FeatureFlagSettingsModal.tsx | 19 ++++- 5 files changed, 86 insertions(+), 114 deletions(-) diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 8c4ff5379..608e04213 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -2167,38 +2167,3 @@ export async function getRevenueDataServer( return serverFetch(request, `log/revenue?${queryParams.toString()}`) } - -// ============================================================================ -// MARK: AI Chat API (Proxy for streaming) -// ============================================================================ - -interface AIChatMessage { - role: 'user' | 'assistant' - content: string -} - -export async function askAIServer( - request: Request, - pid: string, - messages: AIChatMessage[], - timezone: string, -): Promise { - const apiUrl = getApiUrl() - const accessToken = getAccessToken(request) - - const fetchHeaders: Record = { - 'Content-Type': 'application/json', - } - - if (accessToken) { - fetchHeaders['Authorization'] = `Bearer ${accessToken}` - } - - const response = await fetch(`${apiUrl}ai/${pid}/chat`, { - method: 'POST', - headers: fetchHeaders, - body: JSON.stringify({ messages, timezone }), - }) - - return response -} diff --git a/web/app/api/index.ts b/web/app/api/index.ts index 2b9c22b05..a36b72395 100644 --- a/web/app/api/index.ts +++ b/web/app/api/index.ts @@ -1,67 +1,3 @@ -// Feature Flags API -export interface TargetingRule { - column: string - filter: string - isExclusive: boolean -} - -export interface ProjectFeatureFlag { - id: string - key: string - description: string | null - flagType: 'boolean' | 'rollout' - rolloutPercentage: number - targetingRules: TargetingRule[] | null - enabled: boolean - pid: string - created: string -} - -// Experiments (A/B Testing) API - -type ExperimentStatus = 'draft' | 'running' | 'paused' | 'completed' - -type ExposureTrigger = 'feature_flag' | 'custom_event' - -type MultipleVariantHandling = 'exclude' | 'first_exposure' - -type FeatureFlagMode = 'create' | 'link' - -interface ExperimentVariant { - id?: string - name: string - key: string - description?: string | null - rolloutPercentage: number - isControl: boolean -} - -export interface Experiment { - id: string - name: string - description: string | null - hypothesis: string | null - status: ExperimentStatus - // Exposure criteria - exposureTrigger: ExposureTrigger - customEventName: string | null - multipleVariantHandling: MultipleVariantHandling - filterInternalUsers: boolean - // Feature flag configuration - featureFlagMode: FeatureFlagMode - featureFlagKey: string | null - startedAt: string | null - endedAt: string | null - pid: string - goalId: string | null - featureFlagId: string | null - variants: ExperimentVariant[] - created: string -} - -export const DEFAULT_EXPERIMENTS_TAKE = 20 - -// AI Chat API with SSE streaming (proxied through /api/ai) interface AIChatMessage { role: 'user' | 'assistant' content: string @@ -153,11 +89,3 @@ export const askAI = async ( callbacks.onError(error as Error) } } - -// AI Chat History API -export interface AIChatSummary { - id: string - name: string | null - created: string - updated: string -} diff --git a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx index ec4d03101..cbdad5f1f 100644 --- a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx +++ b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx @@ -28,7 +28,7 @@ import sanitizeHtml from 'sanitize-html' import { toast } from 'sonner' import { useStickToBottom } from 'use-stick-to-bottom' -import { askAI, AIChatSummary } from '~/api' +import { askAI } from '~/api' import { ProjectViewActionData } from '~/routes/projects.$id' import SwetrixLogo from '~/ui/icons/SwetrixLogo' import Modal from '~/ui/Modal' @@ -45,6 +45,13 @@ interface MessagePart { args?: unknown } +interface AIChatSummary { + id: string + name: string | null + created: string + updated: string +} + interface Message { id: string role: 'user' | 'assistant' @@ -501,6 +508,8 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { const loadChatFetcher = useFetcher() const saveChatFetcher = useFetcher() const deleteChatFetcher = useFetcher() + const lastProcessedSaveDataRef = useRef(null) + const lastProcessedLoadDataRef = useRef(null) const isLoadingChats = allChatsFetcher.state !== 'idle' @@ -525,7 +534,8 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { formData.append('limit', '3') recentChatsFetcher.submit(formData, { method: 'POST' }) - }, [recentChatsFetcher]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recentChatsFetcher.submit]) // Handle recent chats fetcher response useEffect(() => { @@ -547,7 +557,8 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { allChatsFetcher.submit(formData, { method: 'POST' }) }, - [allChatsFetcher], + // eslint-disable-next-line react-hooks/exhaustive-deps + [allChatsFetcher.submit], ) // Handle all chats fetcher response @@ -586,7 +597,8 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { loadChatFetcher.submit(formData, { method: 'POST' }) }, - [loadChatFetcher], + // eslint-disable-next-line react-hooks/exhaustive-deps + [loadChatFetcher.submit], ) // Handle load chat fetcher response @@ -594,6 +606,10 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { useEffect(() => { if (loadChatFetcher.state === 'idle' && loadChatFetcher.data) { + // Skip if we've already processed this response + if (lastProcessedLoadDataRef.current === loadChatFetcher.data) return + lastProcessedLoadDataRef.current = loadChatFetcher.data + if (loadChatFetcher.data.success && loadChatFetcher.data.data) { const chat = loadChatFetcher.data.data as AIChat setMessages( @@ -625,6 +641,10 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { // Handle save chat fetcher response useEffect(() => { if (saveChatFetcher.state === 'idle' && saveChatFetcher.data) { + // Skip if we've already processed this response + if (lastProcessedSaveDataRef.current === saveChatFetcher.data) return + lastProcessedSaveDataRef.current = saveChatFetcher.data + if (saveChatFetcher.data.success && saveChatFetcher.data.data) { const result = saveChatFetcher.data.data as AIChat if (result.branched || !currentChatId) { @@ -663,7 +683,8 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { saveChatFetcher.submit(formData, { method: 'POST' }) }, - [saveChatFetcher, currentChatId], + // eslint-disable-next-line react-hooks/exhaustive-deps + [saveChatFetcher.submit, currentChatId], ) useEffect(() => { diff --git a/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx b/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx index 322d03154..cdbb9e811 100644 --- a/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx +++ b/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx @@ -20,7 +20,6 @@ import { useTranslation } from 'react-i18next' import { useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' -import { DEFAULT_EXPERIMENTS_TAKE, type Experiment, type ExperimentStatus } from '~/api' import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' import { useCurrentProject } from '~/providers/CurrentProjectProvider' @@ -47,6 +46,48 @@ const STATUS_COLORS: Record = { completed: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-800 dark:text-blue-400' }, } +const DEFAULT_EXPERIMENTS_TAKE = 20 + +type ExperimentStatus = 'draft' | 'running' | 'paused' | 'completed' + +type ExposureTrigger = 'feature_flag' | 'custom_event' + +type MultipleVariantHandling = 'exclude' | 'first_exposure' + +type FeatureFlagMode = 'create' | 'link' + +interface ExperimentVariant { + id?: string + name: string + key: string + description?: string | null + rolloutPercentage: number + isControl: boolean +} + +interface Experiment { + id: string + name: string + description: string | null + hypothesis: string | null + status: ExperimentStatus + // Exposure criteria + exposureTrigger: ExposureTrigger + customEventName: string | null + multipleVariantHandling: MultipleVariantHandling + filterInternalUsers: boolean + // Feature flag configuration + featureFlagMode: FeatureFlagMode + featureFlagKey: string | null + startedAt: string | null + endedAt: string | null + pid: string + goalId: string | null + featureFlagId: string | null + variants: ExperimentVariant[] + created: string +} + interface ExperimentRowProps { experiment: Experiment onDelete: (id: string) => void diff --git a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx index cd81edac7..75189d5a0 100644 --- a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx +++ b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx @@ -18,7 +18,6 @@ import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' -import type { ProjectFeatureFlag, TargetingRule } from '~/api' import { useFiltersProxy } from '~/hooks/useAnalyticsProxy' import { API_URL, isSelfhosted } from '~/lib/constants' import { useTheme } from '~/providers/ThemeProvider' @@ -45,6 +44,24 @@ const TARGETING_COLUMNS = [ { value: 'os', label: 'OS' }, ] +interface TargetingRule { + column: string + filter: string + isExclusive: boolean +} + +interface ProjectFeatureFlag { + id: string + key: string + description: string | null + flagType: 'boolean' | 'rollout' + rolloutPercentage: number + targetingRules: TargetingRule[] | null + enabled: boolean + pid: string + created: string +} + interface FeatureFlagSettingsModalProps { isOpen: boolean onClose: () => void From a017bb0b365f32f35d40b474956bc6f099b8997d Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 03:58:46 +0000 Subject: [PATCH 27/57] uninstall axios --- web/package-lock.json | 93 +------------------------------------------ web/package.json | 2 - 2 files changed, 2 insertions(+), 93 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 5c9fbd342..dff529b98 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,8 +18,6 @@ "@tanstack/react-virtual": "^3.13.13", "@types/leaflet": "^1.9.21", "@ua-parser-js/pro-business": "^2.0.6", - "axios": "^1.13.2", - "axios-auth-refresh": "^3.3.6", "billboard.js": "^3.17.2", "boring-avatars": "^2.0.4", "clsx": "^2.1.1", @@ -4463,12 +4461,6 @@ "node": ">= 0.4" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4495,26 +4487,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/axios-auth-refresh": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/axios-auth-refresh/-/axios-auth-refresh-3.3.6.tgz", - "integrity": "sha512-2CeBUce/SxIfFxow5/n8vApJ97yYF6qoV4gh1UrswT7aEOnlOdBLxxyhOI4IaxGs6BY0l8YujU2jlc4aCmK17Q==", - "license": "MIT", - "peerDependencies": { - "axios": ">= 0.18 < 0.19.0 || >= 0.19.1" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -4880,18 +4852,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -5705,15 +5665,6 @@ "robust-predicates": "^3.0.2" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6040,6 +5991,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7014,26 +6966,6 @@ "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7050,22 +6982,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/formatly": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", @@ -7421,6 +7337,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9509,12 +9426,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/web/package.json b/web/package.json index 1747b6a0b..c765fe052 100644 --- a/web/package.json +++ b/web/package.json @@ -30,8 +30,6 @@ "@tanstack/react-virtual": "^3.13.13", "@types/leaflet": "^1.9.21", "@ua-parser-js/pro-business": "^2.0.6", - "axios": "^1.13.2", - "axios-auth-refresh": "^3.3.6", "billboard.js": "^3.17.2", "boring-avatars": "^2.0.4", "clsx": "^2.1.1", From f06d75e35c7c8d46c2400e9458875fa1b03783d7 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 03:59:35 +0000 Subject: [PATCH 28/57] fix lint --- web/app/pages/Auth/Signin/Signin.tsx | 2 +- web/app/pages/Auth/Signup/Signup.tsx | 2 +- web/app/pages/Dashboard/Dashboard.tsx | 4 ++-- web/app/pages/Dashboard/ProjectCard.tsx | 2 +- web/app/pages/Project/View/ViewProject.tsx | 2 +- web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx | 2 +- web/app/pages/Project/tabs/Funnels/FunnelsView.tsx | 3 +-- web/app/pages/Project/tabs/Goals/GoalsView.tsx | 2 +- web/app/pages/Project/tabs/Sessions/SessionsView.tsx | 2 +- web/app/pages/Project/tabs/Traffic/TrafficView.tsx | 8 ++++---- web/app/pages/UserSettings/components/TwoFA.tsx | 2 +- web/app/routes/gsc-connected.tsx | 2 +- 12 files changed, 16 insertions(+), 17 deletions(-) diff --git a/web/app/pages/Auth/Signin/Signin.tsx b/web/app/pages/Auth/Signin/Signin.tsx index 84628fdb6..fed1f8d79 100644 --- a/web/app/pages/Auth/Signin/Signin.tsx +++ b/web/app/pages/Auth/Signin/Signin.tsx @@ -3,10 +3,10 @@ import { useTranslation, Trans } from 'react-i18next' import { Link, useNavigate, useSearchParams, Form, useActionData, useNavigation, useFetcher } from 'react-router' import { toast } from 'sonner' -import { useAuthProxy } from '~/hooks/useAuthProxy' import GithubAuth from '~/components/GithubAuth' import GoogleAuth from '~/components/GoogleAuth' import OIDCAuth from '~/components/OIDCAuth' +import { useAuthProxy } from '~/hooks/useAuthProxy' import { isSelfhosted, TRIAL_DAYS } from '~/lib/constants' import { SSOProvider } from '~/lib/models/Auth' import { useAuth } from '~/providers/AuthProvider' diff --git a/web/app/pages/Auth/Signup/Signup.tsx b/web/app/pages/Auth/Signup/Signup.tsx index b7c4651e9..50c4912a0 100644 --- a/web/app/pages/Auth/Signup/Signup.tsx +++ b/web/app/pages/Auth/Signup/Signup.tsx @@ -4,10 +4,10 @@ import { useTranslation, Trans } from 'react-i18next' import { Link, useNavigate, Form, useActionData, useNavigation } from 'react-router' import { toast } from 'sonner' -import { useAuthProxy } from '~/hooks/useAuthProxy' import GithubAuth from '~/components/GithubAuth' import GoogleAuth from '~/components/GoogleAuth' import OIDCAuth from '~/components/OIDCAuth' +import { useAuthProxy } from '~/hooks/useAuthProxy' import { HAVE_I_BEEN_PWNED_URL, isSelfhosted, TRIAL_DAYS } from '~/lib/constants' import { SSOProvider } from '~/lib/models/Auth' import { useAuth } from '~/providers/AuthProvider' diff --git a/web/app/pages/Dashboard/Dashboard.tsx b/web/app/pages/Dashboard/Dashboard.tsx index 7e5893aac..7561c7188 100644 --- a/web/app/pages/Dashboard/Dashboard.tsx +++ b/web/app/pages/Dashboard/Dashboard.tsx @@ -10,12 +10,12 @@ import { useLoaderData, useNavigate, useSearchParams, useFetcher, useNavigation, import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { useLiveVisitorsProxy, useOverallStatsProxy } from '~/hooks/useAnalyticsProxy' import DashboardLockedBanner from '~/components/DashboardLockedBanner' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' +import { useLiveVisitorsProxy, useOverallStatsProxy } from '~/hooks/useAnalyticsProxy' import useBreakpoint from '~/hooks/useBreakpoint' import useDebounce from '~/hooks/useDebounce' -import { isSelfhosted, LIVE_VISITORS_UPDATE_INTERVAL, tbPeriodPairs } from '~/lib/constants' +import { isSelfhosted, LIVE_VISITORS_UPDATE_INTERVAL } from '~/lib/constants' import { Overall } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' import type { DashboardLoaderData, DashboardActionData } from '~/routes/dashboard' diff --git a/web/app/pages/Dashboard/ProjectCard.tsx b/web/app/pages/Dashboard/ProjectCard.tsx index 048298c9c..32b715921 100644 --- a/web/app/pages/Dashboard/ProjectCard.tsx +++ b/web/app/pages/Dashboard/ProjectCard.tsx @@ -10,9 +10,9 @@ import { useTranslation } from 'react-i18next' import { Link, useFetcher } from 'react-router' import { toast } from 'sonner' -import type { DashboardActionData } from '~/routes/dashboard' import { OverallObject, Project } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' +import type { DashboardActionData } from '~/routes/dashboard' import { Badge, BadgeProps } from '~/ui/Badge' import Spin from '~/ui/icons/Spin' import Modal from '~/ui/Modal' diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index c11114649..c9ccb927a 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -30,7 +30,6 @@ import { useNavigate, useSearchParams, LinkProps, useFetcher } from 'react-route import { ClientOnly } from 'remix-utils/client-only' import { toast } from 'sonner' -import { ProjectViewActionData } from '~/routes/projects.$id' import EventsRunningOutBanner from '~/components/EventsRunningOutBanner' import Footer from '~/components/Footer' import Header from '~/components/Header' @@ -59,6 +58,7 @@ import { import ViewProjectHotkeys from '~/modals/ViewProjectHotkeys' import { useAuth } from '~/providers/AuthProvider' import { useTheme } from '~/providers/ThemeProvider' +import { ProjectViewActionData } from '~/routes/projects.$id' import Dropdown from '~/ui/Dropdown' import Flag from '~/ui/Flag' import Loader from '~/ui/Loader' diff --git a/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx b/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx index 5cf40fa10..4104a1a5d 100644 --- a/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx +++ b/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx @@ -23,12 +23,12 @@ import { Link, useSearchParams, useFetcher, useLoaderData, useRevalidator } from import { toast } from 'sonner' import type { AlertsResponse } from '~/api/api.server' -import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import { QUERY_METRIC, PLAN_LIMITS, DEFAULT_ALERTS_TAKE } from '~/lib/constants' import { Alerts } from '~/lib/models/Alerts' import PaidFeature from '~/modals/PaidFeature' import { useAuth } from '~/providers/AuthProvider' import { useCurrentProject } from '~/providers/CurrentProjectProvider' +import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import { Badge, type BadgeProps } from '~/ui/Badge' import Button from '~/ui/Button' import Loader from '~/ui/Loader' diff --git a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx index 8b63ea075..a2e851c6b 100644 --- a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx +++ b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx @@ -10,7 +10,6 @@ import { Link, useSearchParams, useFetcher, useLoaderData, useRevalidator } from import { toast } from 'sonner' import type { FunnelDataResponse } from '~/api/api.server' -import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import { FUNNELS_PERIOD_PAIRS } from '~/lib/constants' import { Funnel, AnalyticsFunnel } from '~/lib/models/Project' import NewFunnel from '~/modals/NewFunnel' @@ -18,9 +17,9 @@ import { FunnelChart } from '~/pages/Project/tabs/Funnels/FunnelChart' import FunnelsList from '~/pages/Project/tabs/Funnels/FunnelsList' import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' -import { getFormatDate } from '~/pages/Project/View/ViewProject.helpers' import { useAuth } from '~/providers/AuthProvider' import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import Loader from '~/ui/Loader' import LoadingBar from '~/ui/LoadingBar' import { Text } from '~/ui/Text' diff --git a/web/app/pages/Project/tabs/Goals/GoalsView.tsx b/web/app/pages/Project/tabs/Goals/GoalsView.tsx index cfcde99dd..926413492 100644 --- a/web/app/pages/Project/tabs/Goals/GoalsView.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalsView.tsx @@ -24,7 +24,6 @@ import { toast } from 'sonner' import type { GoalsResponse, Goal, GoalStats, GoalChartData } from '~/api/api.server' import { useGoalStatsProxy, useGoalChartProxy } from '~/hooks/useAnalyticsProxy' -import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import { TimeFormat, tbsFormatMapper, @@ -36,6 +35,7 @@ import { import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' import { useCurrentProject } from '~/providers/CurrentProjectProvider' +import type { ProjectLoaderData, ProjectViewActionData } from '~/routes/projects.$id' import BillboardChart from '~/ui/BillboardChart' import Button from '~/ui/Button' import Spin from '~/ui/icons/Spin' diff --git a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx index 4868db842..b7952d339 100644 --- a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx +++ b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx @@ -7,7 +7,6 @@ import { useSearchParams, useLoaderData, useRevalidator } from 'react-router' import type { SessionsResponse, SessionDetailsResponse } from '~/api/api.server' import { useSessionsProxy } from '~/hooks/useAnalyticsProxy' -import type { ProjectLoaderData } from '~/routes/projects.$id' import { Session, SessionDetails as SessionDetailsType } from '~/lib/models/Project' import NoSessions from '~/pages/Project/tabs/Sessions/components/NoSessions' import { SessionDetailView } from '~/pages/Project/tabs/Sessions/SessionDetailView' @@ -17,6 +16,7 @@ import Filters from '~/pages/Project/View/components/Filters' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' import { getFormatDate } from '~/pages/Project/View/ViewProject.helpers' import { useCurrentProject } from '~/providers/CurrentProjectProvider' +import type { ProjectLoaderData } from '~/routes/projects.$id' import Spin from '~/ui/icons/Spin' import Loader from '~/ui/Loader' import LoadingBar from '~/ui/LoadingBar' diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index 3c8442e05..286da28e2 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -12,16 +12,16 @@ import { useTranslation } from 'react-i18next' import { useNavigate, Link, useSearchParams, useLoaderData, useRevalidator } from 'react-router' import { toast } from 'sonner' +import type { + TrafficLogResponse as ServerTrafficLogResponse, + OverallObject as ServerOverallObject, +} from '~/api/api.server' import { useCustomEventsMetadataProxy, usePropertyMetadataProxy, useGSCKeywordsProxy, useRevenueProxy, } from '~/hooks/useAnalyticsProxy' -import type { - TrafficLogResponse as ServerTrafficLogResponse, - OverallObject as ServerOverallObject, -} from '~/api/api.server' import { useAnnotations } from '~/hooks/useAnnotations' import { TRAFFIC_PANELS_ORDER, chartTypes, isSelfhosted } from '~/lib/constants' import { CountryEntry, Entry } from '~/lib/models/Entry' diff --git a/web/app/pages/UserSettings/components/TwoFA.tsx b/web/app/pages/UserSettings/components/TwoFA.tsx index 469634d3f..19c1acb2b 100644 --- a/web/app/pages/UserSettings/components/TwoFA.tsx +++ b/web/app/pages/UserSettings/components/TwoFA.tsx @@ -2,8 +2,8 @@ import cx from 'clsx' import _isNull from 'lodash/isNull' import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useFetcher } from 'react-router' import QRCode from 'react-qr-code' +import { useFetcher } from 'react-router' import { toast } from 'sonner' import { useAuth } from '~/providers/AuthProvider' diff --git a/web/app/routes/gsc-connected.tsx b/web/app/routes/gsc-connected.tsx index 06162b558..04d79c0fb 100644 --- a/web/app/routes/gsc-connected.tsx +++ b/web/app/routes/gsc-connected.tsx @@ -4,8 +4,8 @@ import type { LoaderFunctionArgs } from 'react-router' import { redirect, useLoaderData, useNavigate } from 'react-router' import type { SitemapFunction } from 'remix-sitemap' -import { useAuthProxy } from '~/hooks/useAuthProxy' import { serverFetch } from '~/api/api.server' +import { useAuthProxy } from '~/hooks/useAuthProxy' import { isSelfhosted } from '~/lib/constants' import StatusPage from '~/ui/StatusPage' import routes from '~/utils/routes' From 3af91fc83eaf2bbf1accbc8aa30d96ff772a188c Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 04:05:25 +0000 Subject: [PATCH 29/57] fix lint (this time actually --- web/app/pages/Auth/Socialised/index.tsx | 2 +- web/app/pages/Billing/index.tsx | 8 ++--- web/app/pages/Onboarding/Onboarding.tsx | 26 ++++++++-------- .../pages/Organisations/Settings/Projects.tsx | 8 +++-- .../pages/Project/Settings/Emails/Emails.tsx | 14 +++++---- .../pages/Project/Settings/People/People.tsx | 18 ++++++----- .../Project/Settings/ProjectSettings.tsx | 11 +------ .../pages/Project/Settings/tabs/Revenue.tsx | 2 +- .../Project/View/components/AddAViewModal.tsx | 7 ++--- .../Project/View/components/SearchFilters.tsx | 3 +- .../pages/Project/tabs/AskAI/AskAIView.tsx | 5 +--- .../pages/Project/tabs/Errors/ErrorsView.tsx | 1 - .../Project/tabs/Funnels/FunnelsView.tsx | 12 +------- .../Project/tabs/Goals/GoalSettingsModal.tsx | 2 +- .../pages/Project/tabs/Goals/GoalsView.tsx | 3 +- .../tabs/Performance/PerformanceView.tsx | 4 --- .../Project/tabs/Sessions/SessionsView.tsx | 1 - .../Project/tabs/Traffic/TrafficView.tsx | 11 +++---- web/app/pages/UserSettings/UserSettings.tsx | 20 +++++++------ .../pages/UserSettings/components/TwoFA.tsx | 30 ++++++++++++------- web/app/routes/api.ai.ts | 2 +- web/app/routes/api.analytics.ts | 2 +- web/app/routes/api.auth.ts | 4 +-- 23 files changed, 92 insertions(+), 104 deletions(-) diff --git a/web/app/pages/Auth/Socialised/index.tsx b/web/app/pages/Auth/Socialised/index.tsx index abe2dc6f3..fc8a8df49 100644 --- a/web/app/pages/Auth/Socialised/index.tsx +++ b/web/app/pages/Auth/Socialised/index.tsx @@ -92,7 +92,7 @@ const Socialised = () => { } else { processCode() } - }, []) + }, [processSSOToken, processSSOTokenCommunityEdition]) if (loading) { return diff --git a/web/app/pages/Billing/index.tsx b/web/app/pages/Billing/index.tsx index 5a976344f..845055077 100644 --- a/web/app/pages/Billing/index.tsx +++ b/web/app/pages/Billing/index.tsx @@ -99,7 +99,7 @@ const Billing = () => { } }, []) - const isTrialEnded = useMemo(() => { + const isTrialEnded = (() => { if (!trialEndDate) { return false } @@ -109,9 +109,9 @@ const Billing = () => { const diff = future.diff(now) return diff < 0 - }, [trialEndDate]) + })() - const trialEndsOnMessage = useMemo(() => { + const trialEndsOnMessage = (() => { if (!trialEndDate || !isTrial) { return null } @@ -137,7 +137,7 @@ const Billing = () => { return t('billing.trialEnds', { date, }) - }, [language, trialEndDate, isTrial, timeFormat, isTrialEnded, t]) + })() const onSubscriptionCancel = () => { if (!subCancelURL) { diff --git a/web/app/pages/Onboarding/Onboarding.tsx b/web/app/pages/Onboarding/Onboarding.tsx index 9d5ee1b45..17b1660ce 100644 --- a/web/app/pages/Onboarding/Onboarding.tsx +++ b/web/app/pages/Onboarding/Onboarding.tsx @@ -89,16 +89,16 @@ const Onboarding = () => { // Handle fetcher responses useEffect(() => { if (fetcher.data?.success) { - const { intent, project: newProject, user: updatedUser } = fetcher.data + const { intent, project: newProject } = fetcher.data if (intent === 'create-project' && newProject) { - setProject(newProject) + setTimeout(() => setProject(newProject), 0) // Update the step after project creation const stepFormData = new FormData() stepFormData.set('intent', 'update-step') stepFormData.set('step', 'setup_tracking') fetcher.submit(stepFormData, { method: 'post' }) - setCurrentStep(2) + setTimeout(() => setCurrentStep(2), 0) } else if (intent === 'update-step') { loadUser() } else if (intent === 'complete-onboarding') { @@ -111,9 +111,9 @@ const Onboarding = () => { } else if (fetcher.data?.error) { toast.error(fetcher.data.error) } else if (fetcher.data?.fieldErrors?.name) { - setNewProjectErrors({ name: fetcher.data.fieldErrors.name }) + setTimeout(() => setNewProjectErrors({ name: fetcher.data!.fieldErrors!.name }), 0) } - }, [fetcher.data, loadUser, logout, navigate, fetcher]) + }, [fetcher.data, loadUser, logout, navigate, fetcher, t]) useEffect(() => { if (user?.hasCompletedOnboarding) { @@ -122,28 +122,30 @@ const Onboarding = () => { } if (!user?.isActive && !isSelfhosted) { - setCurrentStep(0) + setTimeout(() => setCurrentStep(0), 0) return } if (!user?.onboardingStep) { - setCurrentStep(1) + setTimeout(() => setCurrentStep(1), 0) return } switch (user.onboardingStep) { case 'create_project': - setCurrentStep(1) + setTimeout(() => setCurrentStep(1), 0) break case 'setup_tracking': - setCurrentStep(2) + setTimeout(() => setCurrentStep(2), 0) break case 'waiting_for_events': - setCurrentStep(3) - setIsWaitingForEvents(true) + setTimeout(() => { + setCurrentStep(3) + setIsWaitingForEvents(true) + }, 0) break default: - setCurrentStep(1) + setTimeout(() => setCurrentStep(1), 0) } }, [user, navigate]) diff --git a/web/app/pages/Organisations/Settings/Projects.tsx b/web/app/pages/Organisations/Settings/Projects.tsx index 311fbdc2a..3e5aa2611 100644 --- a/web/app/pages/Organisations/Settings/Projects.tsx +++ b/web/app/pages/Organisations/Settings/Projects.tsx @@ -53,13 +53,15 @@ const SelectAProject = ({ onSelect }: SelectAProjectProps) => { // Update local state when fetcher returns data useEffect(() => { if (fetcher.data?.intent === 'get-available-projects' && fetcher.data?.availableProjects) { - setProjects(fetcher.data.availableProjects.results as Project[]) - setTotal(fetcher.data.availableProjects.total) + setTimeout(() => { + setProjects(fetcher.data!.availableProjects!.results as Project[]) + setTotal(fetcher.data!.availableProjects!.total) + }, 0) } }, [fetcher.data]) useEffect(() => { - setCurrentPage(1) + setTimeout(() => setCurrentPage(1), 0) }, [debouncedSearch]) return ( diff --git a/web/app/pages/Project/Settings/Emails/Emails.tsx b/web/app/pages/Project/Settings/Emails/Emails.tsx index 548fbb7d3..7061fc211 100644 --- a/web/app/pages/Project/Settings/Emails/Emails.tsx +++ b/web/app/pages/Project/Settings/Emails/Emails.tsx @@ -298,17 +298,19 @@ const Emails = ({ projectId }: { projectId: string }) => { useEffect(() => { if (loadFetcher.data?.intent === 'get-subscribers') { if (loadFetcher.data.success) { - setEmails(loadFetcher.data.subscribers || []) - setPagination((old) => ({ ...old, count: loadFetcher.data!.subscribersCount || 0 })) + setTimeout(() => { + setEmails(loadFetcher.data!.subscribers || []) + setPagination((old) => ({ ...old, count: loadFetcher.data!.subscribersCount || 0 })) + }, 0) } - setLoading(false) + setTimeout(() => setLoading(false), 0) } }, [loadFetcher.data]) useEffect(() => { if (fetcher.data?.intent === 'add-subscriber') { if (fetcher.data.success && fetcher.data.subscriber) { - setEmails((prev) => [...prev, fetcher.data!.subscriber!]) + setTimeout(() => setEmails((prev) => [...prev, fetcher.data!.subscriber!]), 0) toast.success(t('apiNotifications.userInvited')) } else if (fetcher.data.error) { toast.error(typeof fetcher.data.error === 'string' ? fetcher.data.error : t('apiNotifications.userInviteError')) @@ -319,7 +321,7 @@ const Emails = ({ projectId }: { projectId: string }) => { useEffect(() => { if (removeFetcher.data?.intent === 'remove-subscriber') { if (removeFetcher.data.success) { - setEmails((prev) => _filter(prev, (s) => s.id !== emailToRemove?.id)) + setTimeout(() => setEmails((prev) => _filter(prev, (s) => s.id !== emailToRemove?.id)), 0) } else if (removeFetcher.data.error) { toast.error( typeof removeFetcher.data.error === 'string' ? removeFetcher.data.error : t('apiNotifications.somethingWentWrong'), @@ -346,7 +348,7 @@ const Emails = ({ projectId }: { projectId: string }) => { useEffect(() => { if (showModal) { - validate() + setTimeout(() => validate(), 0) } }, [form]) // eslint-disable-line diff --git a/web/app/pages/Project/Settings/People/People.tsx b/web/app/pages/Project/Settings/People/People.tsx index 5cf3ccaa3..217be2a68 100644 --- a/web/app/pages/Project/Settings/People/People.tsx +++ b/web/app/pages/Project/Settings/People/People.tsx @@ -187,10 +187,12 @@ const People = ({ project }: PeopleProps) => { if (fetcher.data.intent === 'share-project') { if (fetcher.data.success) { toast.success(t('apiNotifications.userInvited')) - setShowModal(false) - setBeenSubmitted(false) - setErrors({}) - setValidated(false) + setTimeout(() => { + setShowModal(false) + setBeenSubmitted(false) + setErrors({}) + setValidated(false) + }, 0) setTimeout(() => setForm({ email: '', role: 'viewer' }), 300) } else if (fetcher.data.error) { toast.error(fetcher.data.error) @@ -198,8 +200,10 @@ const People = ({ project }: PeopleProps) => { } else if (fetcher.data.intent === 'delete-share-user') { if (fetcher.data.success) { toast.success(t('apiNotifications.userRemoved')) - setShowDeleteModal(false) - setMemberToRemove(null) + setTimeout(() => { + setShowDeleteModal(false) + setMemberToRemove(null) + }, 0) } else if (fetcher.data.error) { toast.error(fetcher.data.error) } @@ -229,7 +233,7 @@ const People = ({ project }: PeopleProps) => { useEffect(() => { if (showModal) { - validate() + setTimeout(() => validate(), 0) } }, [form]) // eslint-disable-line diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index 81debfe0c..a27dd230c 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -22,7 +22,7 @@ import { ShieldCheckIcon, DollarSignIcon, } from 'lucide-react' -import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import React, { useState, useEffect, useMemo, useRef } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useLoaderData, useNavigate, Link, useSearchParams, useFetcher } from 'react-router' import { toast } from 'sonner' @@ -762,15 +762,6 @@ const ProjectSettings = () => { } } - const reloadProject = useCallback(async () => { - try { - const result = await fetchProject(id) - if (result) setProject(result as unknown as Project) - } catch (reason: any) { - console.error(`[ERROR] Error while reloading project: ${reason}`) - } - }, [id]) - const title = `${t('project.settings.settings')} ${form.name}` useEffect(() => { diff --git a/web/app/pages/Project/Settings/tabs/Revenue.tsx b/web/app/pages/Project/Settings/tabs/Revenue.tsx index f43ffa21e..bef57202e 100644 --- a/web/app/pages/Project/Settings/tabs/Revenue.tsx +++ b/web/app/pages/Project/Settings/tabs/Revenue.tsx @@ -151,7 +151,7 @@ const Revenue = ({ projectId }: Props) => { } } } - }, [fetcher.state, fetcher.data, t, projectId, selectedCurrency]) + }, [fetcher.state, fetcher.data, t, projectId, selectedCurrency, fetcher]) const handleConnect = () => { if (!apiKey.trim()) { diff --git a/web/app/pages/Project/View/components/AddAViewModal.tsx b/web/app/pages/Project/View/components/AddAViewModal.tsx index 7ff24b4de..1560a28a1 100644 --- a/web/app/pages/Project/View/components/AddAViewModal.tsx +++ b/web/app/pages/Project/View/components/AddAViewModal.tsx @@ -11,7 +11,7 @@ import { toast } from 'sonner' import { useFiltersProxy } from '~/hooks/useAnalyticsProxy' import { FILTERS_PANELS_ORDER } from '~/lib/constants' -import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import { useCurrentProject } from '~/providers/CurrentProjectProvider' import { ProjectViewActionData } from '~/routes/projects.$id' import Combobox from '~/ui/Combobox' import Input from '~/ui/Input' @@ -191,7 +191,6 @@ const MAX_METRICS_IN_VIEW = 3 const AddAViewModal = ({ onSubmit, showModal, setShowModal, tnMapping, defaultView }: AddAViewModalProps) => { const { id } = useCurrentProject() - const projectPassword = useProjectPassword(id) const { t, i18n: { language }, @@ -240,7 +239,7 @@ const AddAViewModal = ({ onSubmit, showModal, setShowModal, tnMapping, defaultVi getFiltersList(filterType) }, [filterType, showModal, getFiltersList]) - const closeModal = () => { + const closeModal = useCallback(() => { setShowModal(false) setTimeout(() => { setName('') @@ -249,7 +248,7 @@ const AddAViewModal = ({ onSubmit, showModal, setShowModal, tnMapping, defaultVi setCustomEvents([]) setErrors({}) }, 300) - } + }, [setShowModal]) const onItemSelect = (item: string) => { let processedItem = item diff --git a/web/app/pages/Project/View/components/SearchFilters.tsx b/web/app/pages/Project/View/components/SearchFilters.tsx index c207a4848..cde55504f 100644 --- a/web/app/pages/Project/View/components/SearchFilters.tsx +++ b/web/app/pages/Project/View/components/SearchFilters.tsx @@ -8,7 +8,7 @@ import { useNavigate, useSearchParams } from 'react-router' import { useFiltersProxy } from '~/hooks/useAnalyticsProxy' import { FILTERS_PANELS_ORDER, ERRORS_FILTERS_PANELS_ORDER } from '~/lib/constants' -import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import { useCurrentProject } from '~/providers/CurrentProjectProvider' import { useTheme } from '~/providers/ThemeProvider' import FilterValueInput, { filterCategoryIcons, createVersionValue, parseVersionValue } from '~/ui/FilterValueInput' import Modal from '~/ui/Modal' @@ -108,7 +108,6 @@ interface SearchFiltersProps { const SearchFilters = ({ showModal, setShowModal, tnMapping, filters, type }: SearchFiltersProps) => { const { id } = useCurrentProject() - const projectPassword = useProjectPassword(id) const { theme } = useTheme() const { t, diff --git a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx index cbdad5f1f..3a548d7f7 100644 --- a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx +++ b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx @@ -602,8 +602,6 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { ) // Handle load chat fetcher response - const [pendingChatId, setPendingChatId] = useState(null) - useEffect(() => { if (loadChatFetcher.state === 'idle' && loadChatFetcher.data) { // Skip if we've already processed this response @@ -626,13 +624,11 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { newParams.delete('chat') setSearchParams(newParams) } - setPendingChatId(null) } }, [loadChatFetcher.state, loadChatFetcher.data, searchParams, setSearchParams]) const loadChatById = useCallback( (chatId: string) => { - setPendingChatId(chatId) loadChat(chatId) }, [loadChat], @@ -747,6 +743,7 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { loadRecentChats, loadAllChatsWithSkip, t, + handleNewChat, ]) const handleDeleteChat = (chatId: string) => { diff --git a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx index bb01ee796..3c91ea06f 100644 --- a/web/app/pages/Project/tabs/Errors/ErrorsView.tsx +++ b/web/app/pages/Project/tabs/Errors/ErrorsView.tsx @@ -472,7 +472,6 @@ const ErrorsViewInner = ({ deferredData }: ErrorsViewInnerProps) => { const [canLoadMoreErrors, setCanLoadMoreErrors] = useState( () => (deferredData.errorsData?.errors?.length || 0) >= ERRORS_TAKE, ) - const errorsRequestIdRef = useRef(0) const activeEID = useMemo(() => searchParams.get('eid'), [searchParams]) diff --git a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx index a2e851c6b..3dd2418d0 100644 --- a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx +++ b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx @@ -53,7 +53,7 @@ const FunnelsViewInner = ({ deferredData }: FunnelsViewInnerProps) => { const projectPassword = useProjectPassword(id) const revalidator = useRevalidator() const { isAuthenticated } = useAuth() - const { timezone, period, dateRange, periodPairs, funnelsRefreshTrigger } = useViewProjectContext() + const { periodPairs, funnelsRefreshTrigger } = useViewProjectContext() // Filter periods to only include those valid for funnels const timeBucketSelectorItems = useMemo(() => { @@ -196,16 +196,6 @@ const FunnelsViewInner = ({ deferredData }: FunnelsViewInnerProps) => { } }, [deferredData.funnelData, revalidator.state]) - const loadFunnelsData = () => { - if (!activeFunnel?.id) { - return - } - - setDataLoading(true) - // Use revalidator to trigger a new loader fetch with updated URL params - revalidator.revalidate() - } - const funnelSummary = useMemo(() => { if (!funnelAnalytics || _isEmpty(funnelAnalytics.funnel)) { return null diff --git a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx index 9370f33bc..f6a381140 100644 --- a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx @@ -32,7 +32,7 @@ interface GoalSettingsModalProps { goalId?: string | null } -const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId, goalId }: GoalSettingsModalProps) => { +const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId: _projectId, goalId }: GoalSettingsModalProps) => { const { t } = useTranslation() const fetcher = useFetcher() const processedRef = useRef(null) diff --git a/web/app/pages/Project/tabs/Goals/GoalsView.tsx b/web/app/pages/Project/tabs/Goals/GoalsView.tsx index 926413492..0b4dfea22 100644 --- a/web/app/pages/Project/tabs/Goals/GoalsView.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalsView.tsx @@ -448,8 +448,7 @@ const GoalsViewInner = ({ period, from = '', to = '', timezone, deferredData }: const { id } = useCurrentProject() const revalidator = useRevalidator() const { goalsRefreshTrigger, timeBucket, timeFormat } = useViewProjectContext() - const [searchParams] = useSearchParams() - const isEmbedded = searchParams.get('embedded') === 'true' + const [_searchParams] = useSearchParams() const { t } = useTranslation() const fetcher = useFetcher() diff --git a/web/app/pages/Project/tabs/Performance/PerformanceView.tsx b/web/app/pages/Project/tabs/Performance/PerformanceView.tsx index 135730f90..aeb61e41b 100644 --- a/web/app/pages/Project/tabs/Performance/PerformanceView.tsx +++ b/web/app/pages/Project/tabs/Performance/PerformanceView.tsx @@ -77,13 +77,10 @@ const PerformanceViewInner = ({ tnMapping, deferredData }: PerformanceViewInnerP const revalidator = useRevalidator() const { performanceRefreshTrigger, - timezone, filters, timeFormat, timeBucket, - activePeriod, // Comparison state from context (not implemented via SSR yet) - isActiveCompare, activePeriodCompare, // Chart state from context chartType, @@ -123,7 +120,6 @@ const PerformanceViewInner = ({ tnMapping, deferredData }: PerformanceViewInnerP const { theme } = useTheme() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() - const isEmbedded = searchParams.get('embedded') === 'true' // State for data - initialized from loader, can be overridden by client-side fetches const [dataLoading, setDataLoading] = useState(false) diff --git a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx index b7952d339..d9aab3872 100644 --- a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx +++ b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx @@ -113,7 +113,6 @@ const SessionsViewInner = ({ tnMapping, rotateXAxis, deferredData }: SessionsVie const [error, setError] = useState(null) const isMountedRef = useRef(true) - const prevActivePSIDRef = useRef(null) const activePSID = useMemo(() => { return searchParams.get('psid') diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index 286da28e2..bce6670c7 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -60,7 +60,7 @@ import { getDeviceRowMapper, onCSVExportClick, } from '~/pages/Project/View/ViewProject.helpers' -import { useCurrentProject, useProjectPassword } from '~/providers/CurrentProjectProvider' +import { useCurrentProject } from '~/providers/CurrentProjectProvider' import { useTheme } from '~/providers/ThemeProvider' import type { ProjectLoaderData } from '~/routes/projects.$id' import Checkbox from '~/ui/Checkbox' @@ -143,7 +143,7 @@ const TrafficViewInner = ({ customMetrics, onRemoveCustomMetric, resetCustomMetrics, - mode, + mode: _mode, projectViews, projectViewsLoading, loadProjectViews, @@ -153,7 +153,6 @@ const TrafficViewInner = ({ deferredData, }: TrafficViewInnerProps) => { const { id, project, allowedToManage } = useCurrentProject() - const projectPassword = useProjectPassword(id) const revalidator = useRevalidator() const { fetchMetadata: fetchCustomEventsMetadata } = useCustomEventsMetadataProxy() const { fetchMetadata: fetchPropertyMetadata } = usePropertyMetadataProxy() @@ -170,9 +169,7 @@ const TrafficViewInner = ({ activePeriod, // Comparison state from context isActiveCompare, - dateRangeCompare, activePeriodCompare, - compareDisable, // Chart state from context chartType, setChartTypeOnClick, @@ -194,7 +191,6 @@ const TrafficViewInner = ({ const { theme } = useTheme() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() - const isEmbedded = searchParams.get('embedded') === 'true' // Annotations hook const { @@ -590,6 +586,7 @@ const TrafficViewInner = ({ timeBucket, baseChartData, id, + fetchRevenueData, ]) const getCustomEventMetadata = async (event: string) => { @@ -681,7 +678,7 @@ const TrafficViewInner = ({ // Load revenue data when revenue metric is enabled useEffect(() => { loadRevenueData() - }, [loadRevenueData]) + }, [loadRevenueData, fetchRevenueData]) // Handle refresh trigger - use revalidator for URL-based data useEffect(() => { diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index 04c0177ab..eddad76f2 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -189,7 +189,7 @@ const UserSettings = () => { } useEffect(() => { - validate() + setTimeout(() => validate(), 0) }, [form]) // eslint-disable-line useEffect(() => { @@ -197,14 +197,16 @@ const UserSettings = () => { return } - setForm((prev) => ({ - ...prev, - email: user.email || '', - timeFormat: user.timeFormat || TimeFormat['12-hour'], - })) - setTimezone(user.timezone || DEFAULT_TIMEZONE) - setReportFrequency(user.reportFrequency) - setFormPreset(true) + setTimeout(() => { + setForm((prev) => ({ + ...prev, + email: user.email || '', + timeFormat: user.timeFormat || TimeFormat['12-hour'], + })) + setTimezone(user.timezone || DEFAULT_TIMEZONE) + setReportFrequency(user.reportFrequency) + setFormPreset(true) + }, 0) }, [isLoading, user, formPreset]) const handleInput = (event: React.ChangeEvent) => { diff --git a/web/app/pages/UserSettings/components/TwoFA.tsx b/web/app/pages/UserSettings/components/TwoFA.tsx index 19c1acb2b..b9770c86e 100644 --- a/web/app/pages/UserSettings/components/TwoFA.tsx +++ b/web/app/pages/UserSettings/components/TwoFA.tsx @@ -35,28 +35,38 @@ const TwoFA = () => { const { intent, twoFAData } = fetcher.data if (intent === 'generate-2fa' && twoFAData) { - setTwoFAConfigurating(true) - setTwoFAConfigData({ secret: twoFAData.secret, otpauthUrl: twoFAData.otpauthUrl }) + setTimeout(() => { + setTwoFAConfigurating(true) + setTwoFAConfigData({ secret: twoFAData.secret, otpauthUrl: twoFAData.otpauthUrl }) + }, 0) } else if (intent === 'enable-2fa' && twoFAData?.twoFactorRecoveryCode) { mergeUser({ isTwoFactorAuthenticationEnabled: true }) - setTwoFARecovery(twoFAData.twoFactorRecoveryCode) - setTwoFACode('') + setTimeout(() => { + setTwoFARecovery(twoFAData.twoFactorRecoveryCode) + setTwoFACode('') + }, 0) } else if (intent === 'disable-2fa') { mergeUser({ isTwoFactorAuthenticationEnabled: false }) - setTwoFADisabling(false) - setTwoFACode('') + setTimeout(() => { + setTwoFADisabling(false) + setTwoFACode('') + }, 0) } } else if (fetcher.data?.error) { const { intent, error } = fetcher.data if (intent === 'enable-2fa' || intent === 'disable-2fa') { - setTwoFACodeError(t('profileSettings.invalid2fa')) - setTwoFACode('') + setTimeout(() => { + setTwoFACodeError(t('profileSettings.invalid2fa')) + setTwoFACode('') + }, 0) } else { toast.error(error) if (intent === 'generate-2fa') { - setTwoFAConfigurating(false) - setTwoFAConfigData({}) + setTimeout(() => { + setTwoFAConfigurating(false) + setTwoFAConfigData({}) + }, 0) } } } diff --git a/web/app/routes/api.ai.ts b/web/app/routes/api.ai.ts index 9bef5a8be..bb7413ec2 100644 --- a/web/app/routes/api.ai.ts +++ b/web/app/routes/api.ai.ts @@ -35,6 +35,6 @@ export async function action({ request }: ActionFunctionArgs) { } } -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ request: _request }: LoaderFunctionArgs) { return data({ error: 'Use POST method' }, { status: 405 }) } diff --git a/web/app/routes/api.analytics.ts b/web/app/routes/api.analytics.ts index cf21c91e0..b4df6dce6 100644 --- a/web/app/routes/api.analytics.ts +++ b/web/app/routes/api.analytics.ts @@ -616,6 +616,6 @@ export async function action({ request }: ActionFunctionArgs) { } } -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ request: _request }: LoaderFunctionArgs) { return data({ error: 'Use POST method' }, { status: 405 }) } diff --git a/web/app/routes/api.auth.ts b/web/app/routes/api.auth.ts index 5fe55b6ff..5c01c6995 100644 --- a/web/app/routes/api.auth.ts +++ b/web/app/routes/api.auth.ts @@ -62,7 +62,7 @@ export async function action({ request }: ActionFunctionArgs) { const result = await getJWTBySSOHashServer(request, body.hash, body.provider) if (result.data) { - const { accessToken, refreshToken, user } = result.data + const { accessToken, refreshToken } = result.data const cookies = createAuthCookies({ accessToken, refreshToken }, body.remember ?? false) return data>( @@ -149,6 +149,6 @@ export async function action({ request }: ActionFunctionArgs) { } } -export async function loader({ request }: LoaderFunctionArgs) { +export async function loader({ request: _request }: LoaderFunctionArgs) { return data({ error: 'Use POST method' }, { status: 405 }) } From a0d0b907099f9c44e55c0cbd760fe3c4a1d98144 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 04:05:38 +0000 Subject: [PATCH 30/57] format --- web/app/components/pricing/BillingPricing.tsx | 29 ++++++++++++++----- web/app/pages/Dashboard/ProjectCard.tsx | 3 +- .../pages/Organisations/OrganisationCard.tsx | 6 +--- .../pages/Project/Settings/Emails/Emails.tsx | 8 +++-- .../pages/Project/Settings/tabs/Revenue.tsx | 19 ++++-------- web/app/pages/Project/View/ViewProject.tsx | 1 - .../tabs/FeatureFlags/FeatureFlagsView.tsx | 7 +---- .../Project/tabs/Funnels/FunnelsView.tsx | 4 +-- .../pages/Project/tabs/Goals/GoalsView.tsx | 4 ++- .../Project/tabs/Traffic/TrafficView.tsx | 5 +++- web/app/routes/dashboard.tsx | 10 ++----- web/app/routes/login.tsx | 26 ++++++++++------- 12 files changed, 64 insertions(+), 58 deletions(-) diff --git a/web/app/components/pricing/BillingPricing.tsx b/web/app/components/pricing/BillingPricing.tsx index a02fbee76..f47f59ab2 100644 --- a/web/app/components/pricing/BillingPricing.tsx +++ b/web/app/components/pricing/BillingPricing.tsx @@ -102,7 +102,10 @@ const BillingPricing = ({ lastEvent, metainfo = DEFAULT_METAINFO }: BillingPrici // Handle preview fetcher error useEffect(() => { if (previewFetcher.data?.error) { - console.error('[ERROR] An error occured while loading subscription update pricing preview:', previewFetcher.data.error) + console.error( + '[ERROR] An error occured while loading subscription update pricing preview:', + previewFetcher.data.error, + ) toast.error('An error occured while loading subscription update pricing preview') } }, [previewFetcher.data?.error]) @@ -460,8 +463,12 @@ const BillingPricing = ({ lastEvent, metainfo = DEFAULT_METAINFO }: BillingPrici {language === 'en' - ? dayjs((subUpdatePreview as any).immediatePayment.date).locale(language).format('MMMM D, YYYY') - : dayjs((subUpdatePreview as any).immediatePayment.date).locale(language).format('D MMMM, YYYY')} + ? dayjs((subUpdatePreview as any).immediatePayment.date) + .locale(language) + .format('MMMM D, YYYY') + : dayjs((subUpdatePreview as any).immediatePayment.date) + .locale(language) + .format('D MMMM, YYYY')} @@ -474,8 +481,12 @@ const BillingPricing = ({ lastEvent, metainfo = DEFAULT_METAINFO }: BillingPrici dueNowAmount: -(subUpdatePreview as any).immediatePayment.amount, dueNowDate: language === 'en' - ? dayjs((subUpdatePreview as any).immediatePayment.date).locale(language).format('MMMM D, YYYY') - : dayjs((subUpdatePreview as any).immediatePayment.date).locale(language).format('D MMMM, YYYY'), + ? dayjs((subUpdatePreview as any).immediatePayment.date) + .locale(language) + .format('MMMM D, YYYY') + : dayjs((subUpdatePreview as any).immediatePayment.date) + .locale(language) + .format('D MMMM, YYYY'), nextPaymentAmount: (subUpdatePreview as any).nextPayment.amount, })}

@@ -506,8 +517,12 @@ const BillingPricing = ({ lastEvent, metainfo = DEFAULT_METAINFO }: BillingPrici {language === 'en' - ? dayjs((subUpdatePreview as any).nextPayment.date).locale(language).format('MMMM D, YYYY') - : dayjs((subUpdatePreview as any).nextPayment.date).locale(language).format('D MMMM, YYYY')} + ? dayjs((subUpdatePreview as any).nextPayment.date) + .locale(language) + .format('MMMM D, YYYY') + : dayjs((subUpdatePreview as any).nextPayment.date) + .locale(language) + .format('D MMMM, YYYY')} diff --git a/web/app/pages/Dashboard/ProjectCard.tsx b/web/app/pages/Dashboard/ProjectCard.tsx index 32b715921..0ecea5e46 100644 --- a/web/app/pages/Dashboard/ProjectCard.tsx +++ b/web/app/pages/Dashboard/ProjectCard.tsx @@ -149,7 +149,8 @@ export const ProjectCard = ({ const faviconHost = useMemo(() => getFaviconHost(websiteUrl || null), [websiteUrl]) const [localIsPinned, setLocalIsPinned] = useState(isPinned) - const isPinning = fetcher.state === 'submitting' && + const isPinning = + fetcher.state === 'submitting' && (fetcher.formData?.get('intent') === 'pin-project' || fetcher.formData?.get('intent') === 'unpin-project') // Sync local state with prop diff --git a/web/app/pages/Organisations/OrganisationCard.tsx b/web/app/pages/Organisations/OrganisationCard.tsx index eb542fa3d..d338748ad 100644 --- a/web/app/pages/Organisations/OrganisationCard.tsx +++ b/web/app/pages/Organisations/OrganisationCard.tsx @@ -153,11 +153,7 @@ export const OrganisationCard = ({ organisation }: OrganisationCardProps) => { } return ( - + {cardContent} ) diff --git a/web/app/pages/Project/Settings/Emails/Emails.tsx b/web/app/pages/Project/Settings/Emails/Emails.tsx index 7061fc211..a911e5b2c 100644 --- a/web/app/pages/Project/Settings/Emails/Emails.tsx +++ b/web/app/pages/Project/Settings/Emails/Emails.tsx @@ -158,7 +158,9 @@ const EmailList = ({ data, onRemove, setEmails }: EmailListProps) => { toast.success(t('apiNotifications.updatedPeriodEmailReports')) } else if (fetcher.data.error) { toast.error( - typeof fetcher.data.error === 'string' ? fetcher.data.error : t('apiNotifications.updatedPeriodEmailReportsError'), + typeof fetcher.data.error === 'string' + ? fetcher.data.error + : t('apiNotifications.updatedPeriodEmailReportsError'), ) } } @@ -324,7 +326,9 @@ const Emails = ({ projectId }: { projectId: string }) => { setTimeout(() => setEmails((prev) => _filter(prev, (s) => s.id !== emailToRemove?.id)), 0) } else if (removeFetcher.data.error) { toast.error( - typeof removeFetcher.data.error === 'string' ? removeFetcher.data.error : t('apiNotifications.somethingWentWrong'), + typeof removeFetcher.data.error === 'string' + ? removeFetcher.data.error + : t('apiNotifications.somethingWentWrong'), ) } } diff --git a/web/app/pages/Project/Settings/tabs/Revenue.tsx b/web/app/pages/Project/Settings/tabs/Revenue.tsx index bef57202e..2fe0561d3 100644 --- a/web/app/pages/Project/Settings/tabs/Revenue.tsx +++ b/web/app/pages/Project/Settings/tabs/Revenue.tsx @@ -88,18 +88,12 @@ const Revenue = ({ projectId }: Props) => { const [selectedProvider, setSelectedProvider] = useState('stripe') const [selectedCurrency, setSelectedCurrency] = useState<'USD' | 'EUR' | 'GBP'>('USD') - const isConnecting = - fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'connect-revenue' - const isDisconnecting = - fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'disconnect-revenue' - const isSavingCurrency = - fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'update-revenue-currency' + const isConnecting = fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'connect-revenue' + const isDisconnecting = fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'disconnect-revenue' + const isSavingCurrency = fetcher.state !== 'idle' && fetcher.formData?.get('intent') === 'update-revenue-currency' useEffect(() => { - fetcher.submit( - { intent: 'get-revenue-status' }, - { method: 'POST', action: `/projects/settings/${projectId}` }, - ) + fetcher.submit({ intent: 'get-revenue-status' }, { method: 'POST', action: `/projects/settings/${projectId}` }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]) @@ -166,10 +160,7 @@ const Revenue = ({ projectId }: Props) => { } const handleDisconnect = () => { - fetcher.submit( - { intent: 'disconnect-revenue' }, - { method: 'POST', action: `/projects/settings/${projectId}` }, - ) + fetcher.submit({ intent: 'disconnect-revenue' }, { method: 'POST', action: `/projects/settings/${projectId}` }) } const handleCurrencyChange = () => { diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index c9ccb927a..4abfe3b5d 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -444,7 +444,6 @@ const ViewProjectContent = () => { } }, [viewsLoadFetcher.state, viewsLoadFetcher.data]) - const getVersionFilterLink = useCallback( (parent: string | null, version: string | null, panelType: 'br' | 'os') => { const filterParams = new URLSearchParams(searchParams.toString()) diff --git a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx index 65074bc31..b52e6d7d6 100644 --- a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx +++ b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx @@ -25,12 +25,7 @@ import { useTranslation } from 'react-i18next' import { Link, useLocation, useFetcher, useLoaderData, useRevalidator } from 'react-router' import { toast } from 'sonner' -import type { - FeatureFlagsResponse, - ProjectFeatureFlag, - FeatureFlagStats, - FeatureFlagProfile, -} from '~/api/api.server' +import type { FeatureFlagsResponse, ProjectFeatureFlag, FeatureFlagStats, FeatureFlagProfile } from '~/api/api.server' import { useFeatureFlagStatsProxy, useFeatureFlagProfilesProxy } from '~/hooks/useAnalyticsProxy' import DashboardHeader from '~/pages/Project/View/components/DashboardHeader' import { useViewProjectContext } from '~/pages/Project/View/ViewProject' diff --git a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx index 3dd2418d0..a85eed04a 100644 --- a/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx +++ b/web/app/pages/Project/tabs/Funnels/FunnelsView.tsx @@ -73,7 +73,7 @@ const FunnelsViewInner = ({ deferredData }: FunnelsViewInnerProps) => { const [isNewFunnelOpened, setIsNewFunnelOpened] = useState(false) const [funnelToEdit, setFunnelToEdit] = useState(undefined) const [dataLoading, setDataLoading] = useState(false) - + // Initialize funnelAnalytics from loader data const [funnelAnalytics, setFunnelAnalytics] = useState<{ funnel: AnalyticsFunnel[] @@ -218,7 +218,7 @@ const FunnelsViewInner = ({ deferredData }: FunnelsViewInnerProps) => { // The actual data fetching happens via the loader when URL changes useEffect(() => { if (!project || !activeFunnel) return - + // If we have loader data for this funnel, use it if (deferredData.funnelData) { setFunnelAnalytics({ diff --git a/web/app/pages/Project/tabs/Goals/GoalsView.tsx b/web/app/pages/Project/tabs/Goals/GoalsView.tsx index 0b4dfea22..318360648 100644 --- a/web/app/pages/Project/tabs/Goals/GoalsView.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalsView.tsx @@ -435,7 +435,9 @@ function GoalsDataResolver({ children }: { children: (data: DeferredGoalsData) = function GoalsViewWrapper(props: GoalsViewProps) { return ( }> - {(deferredData) => } + + {(deferredData) => } + ) } diff --git a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx index bce6670c7..75efa997f 100644 --- a/web/app/pages/Project/tabs/Traffic/TrafficView.tsx +++ b/web/app/pages/Project/tabs/Traffic/TrafficView.tsx @@ -242,7 +242,10 @@ const TrafficViewInner = ({ return {} }, [deferredData.overallCompareStats, id]) - const dataChartCompare = useMemo(() => deferredData.trafficCompareData?.chart || {}, [deferredData.trafficCompareData]) + const dataChartCompare = useMemo( + () => deferredData.trafficCompareData?.chart || {}, + [deferredData.trafficCompareData], + ) // Derive custom events data from loader const customEventsChartData = useMemo( diff --git a/web/app/routes/dashboard.tsx b/web/app/routes/dashboard.tsx index 4ebc35a52..dcc727e54 100644 --- a/web/app/routes/dashboard.tsx +++ b/web/app/routes/dashboard.tsx @@ -43,10 +43,7 @@ export async function action({ request }: ActionFunctionArgs) { const organisationId = formData.get('organisationId')?.toString() if (!name.trim()) { - return data( - { intent, fieldErrors: { name: 'Project name is required' } }, - { status: 400 }, - ) + return data({ intent, fieldErrors: { name: 'Project name is required' } }, { status: 400 }) } if (name.length > 50) { @@ -131,10 +128,7 @@ export async function action({ request }: ActionFunctionArgs) { return data({ intent, error: result.error as string }, { status: 400 }) } - return data( - { intent, success: true }, - { headers: createHeadersWithCookies(result.cookies) }, - ) + return data({ intent, success: true }, { headers: createHeadersWithCookies(result.cookies) }) } default: diff --git a/web/app/routes/login.tsx b/web/app/routes/login.tsx index 38d870ff3..dc53a4e2d 100644 --- a/web/app/routes/login.tsx +++ b/web/app/routes/login.tsx @@ -45,20 +45,26 @@ export async function action({ request }: ActionFunctionArgs) { const dontRemember = formData.get('dontRemember') === 'true' if (!twoFactorAuthenticationCode || twoFactorAuthenticationCode.length !== 6) { - return data({ fieldErrors: { twoFACode: 'Please enter a valid 6-digit code' }, timestamp: Date.now() }, { status: 400 }) + return data( + { fieldErrors: { twoFACode: 'Please enter a valid 6-digit code' }, timestamp: Date.now() }, + { status: 400 }, + ) } - const result = await serverFetch<{ accessToken: string; refreshToken: string; user: { hasCompletedOnboarding: boolean } }>( - request, - '2fa/authenticate', - { - method: 'POST', - body: { twoFactorAuthenticationCode }, - }, - ) + const result = await serverFetch<{ + accessToken: string + refreshToken: string + user: { hasCompletedOnboarding: boolean } + }>(request, '2fa/authenticate', { + method: 'POST', + body: { twoFactorAuthenticationCode }, + }) if (result.error) { - return data({ fieldErrors: { twoFACode: 'Invalid 2FA code' }, timestamp: Date.now() }, { status: 400 }) + return data( + { fieldErrors: { twoFACode: 'Invalid 2FA code' }, timestamp: Date.now() }, + { status: 400 }, + ) } const { accessToken, refreshToken, user } = result.data! From 1fd765a501bba10e6df5e5dbff68310c9112b470 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 13:25:07 +0000 Subject: [PATCH 31/57] fixes --- .../pages/Project/Settings/Emails/Emails.tsx | 22 ++++++++++++------- .../pages/Project/Settings/People/People.tsx | 2 +- .../tabs/Alerts/ProjectAlertsSettings.tsx | 11 ---------- .../Project/tabs/Goals/GoalSettingsModal.tsx | 1 + 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/web/app/pages/Project/Settings/Emails/Emails.tsx b/web/app/pages/Project/Settings/Emails/Emails.tsx index a911e5b2c..339349a16 100644 --- a/web/app/pages/Project/Settings/Emails/Emails.tsx +++ b/web/app/pages/Project/Settings/Emails/Emails.tsx @@ -287,6 +287,7 @@ const Emails = ({ projectId }: { projectId: string }) => { const [showDeleteModal, setShowDeleteModal] = useState(false) const [emailToRemove, setEmailToRemove] = useState(null) + const removingEmailId = useRef(null) const isDeleting = removeFetcher.state !== 'idle' @@ -302,7 +303,7 @@ const Emails = ({ projectId }: { projectId: string }) => { if (loadFetcher.data.success) { setTimeout(() => { setEmails(loadFetcher.data!.subscribers || []) - setPagination((old) => ({ ...old, count: loadFetcher.data!.subscribersCount || 0 })) + setPagination((old) => ({ ...old, total: loadFetcher.data!.subscribersCount || 0 })) }, 0) } setTimeout(() => setLoading(false), 0) @@ -321,9 +322,15 @@ const Emails = ({ projectId }: { projectId: string }) => { }, [fetcher.data, t]) useEffect(() => { - if (removeFetcher.data?.intent === 'remove-subscriber') { + if (removeFetcher.state === 'idle' && removeFetcher.data?.intent === 'remove-subscriber') { if (removeFetcher.data.success) { - setTimeout(() => setEmails((prev) => _filter(prev, (s) => s.id !== emailToRemove?.id)), 0) + const capturedId = removingEmailId.current + setTimeout(() => { + setEmails((prev) => _filter(prev, (s) => s.id !== capturedId)) + setShowDeleteModal(false) + setEmailToRemove(null) + }, 0) + removingEmailId.current = null } else if (removeFetcher.data.error) { toast.error( typeof removeFetcher.data.error === 'string' @@ -332,7 +339,7 @@ const Emails = ({ projectId }: { projectId: string }) => { ) } } - }, [removeFetcher.data, emailToRemove, t]) + }, [removeFetcher.data, removeFetcher.state, t]) const validate = () => { const allErrors: { @@ -404,6 +411,7 @@ const Emails = ({ projectId }: { projectId: string }) => { return } + removingEmailId.current = emailId removeFetcher.submit( { intent: 'remove-subscriber', subscriberId: emailId }, { method: 'post', action: `/projects/settings/${projectId}` }, @@ -489,10 +497,8 @@ const Emails = ({ projectId }: { projectId: string }) => { setShowDeleteModal(false) setEmailToRemove(null) }} - onSubmit={async () => { - await onRemove(emailToRemove!.id) - setEmailToRemove(null) - setShowDeleteModal(false) + onSubmit={() => { + onRemove(emailToRemove!.id) }} submitText={t('common.yes')} type='confirmed' diff --git a/web/app/pages/Project/Settings/People/People.tsx b/web/app/pages/Project/Settings/People/People.tsx index 217be2a68..44b99b818 100644 --- a/web/app/pages/Project/Settings/People/People.tsx +++ b/web/app/pages/Project/Settings/People/People.tsx @@ -353,7 +353,7 @@ const People = ({ project }: PeopleProps) => { title={t('project.settings.removeUser', { user: memberToRemove?.user?.email })} message={t('project.settings.removeConfirm')} isOpened={showDeleteModal} - isLoading={isSubmitting ? fetcher.formData?.get('intent') === 'delete-share-user' : null} + isLoading={isSubmitting ? (fetcher.formData?.get('intent') === 'delete-share-user') : undefined} /> {isSettings && alertId ? : null} - - {form.queryCustomEvent ? : null} - {form.alertOnNewErrorsOnly !== undefined ? ( - - ) : null} - {form.alertOnEveryCustomEvent !== undefined ? ( - - ) : null} {shouldIncludeQueryFields && form.queryCondition ? ( ) : null} - {shouldIncludeQueryFields && form.queryValue !== undefined ? ( - - ) : null} {shouldIncludeQueryFields && form.queryTime ? ( ) : null} diff --git a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx index f6a381140..556aa6dc6 100644 --- a/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx +++ b/web/app/pages/Project/tabs/Goals/GoalSettingsModal.tsx @@ -129,6 +129,7 @@ const GoalSettingsModal = ({ isOpen, onClose, onSuccess, projectId: _projectId, formData.set('type', type) formData.set('matchType', matchType) formData.set('value', value) + formData.set('metadataFilters', JSON.stringify(metadataFilters)) if (!isNew && goalId) { formData.set('goalId', goalId) From 292dbb5ee1735bea2b078881ae6771082c1bace3 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 16:18:21 +0000 Subject: [PATCH 32/57] minor code improvements --- web/app/api/api.server.ts | 45 ++++++----------- web/app/hooks/useAnnotations.ts | 5 +- web/app/lib/models/Alerts.ts | 1 + web/app/modals/AnnotationModal.tsx | 2 - web/app/pages/Onboarding/Onboarding.tsx | 8 +++ .../pages/Organisations/Settings/index.tsx | 7 +++ .../Project/Settings/ProjectSettings.tsx | 4 +- .../tabs/Alerts/ProjectAlertsSettings.tsx | 6 ++- .../Project/tabs/Alerts/ProjectAlertsView.tsx | 4 +- .../pages/Project/tabs/AskAI/AskAIView.tsx | 4 +- .../tabs/Experiments/ExperimentsView.tsx | 7 --- .../FeatureFlags/FeatureFlagSettingsModal.tsx | 49 ++++++++----------- .../Project/tabs/Profiles/ProfilesView.tsx | 2 +- web/app/pages/UserSettings/UserSettings.tsx | 17 +++++-- web/app/providers/CurrentProjectProvider.tsx | 40 +++++++++++---- web/app/routes/recovery.tsx | 14 +++++- web/app/utils/session.server.ts | 13 ++++- 17 files changed, 136 insertions(+), 92 deletions(-) diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 608e04213..8a599b414 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -1,3 +1,4 @@ +import { Alerts } from '~/lib/models/Alerts' import { Auth } from '~/lib/models/Auth' import { User } from '~/lib/models/User' import { @@ -6,6 +7,7 @@ import { createAuthCookies, clearAuthCookies, AuthTokens, + isPersistentSession, } from '~/utils/session.server' // ============================================================================ @@ -216,11 +218,19 @@ export async function streamingServerFetch( if (refreshResult.success && refreshResult.tokens) { fetchHeaders['Authorization'] = `Bearer ${refreshResult.tokens.accessToken}` - return fetch(url, { + const retryResponse = await fetch(url, { method, headers: fetchHeaders, body: body ? JSON.stringify(body) : undefined, }) + + // If the response is not ok, we still want to propagate the refreshed cookies + const finalResponse = new Response(retryResponse.body, retryResponse) + for (const cookie of refreshResult.cookies) { + finalResponse.headers.append('Set-Cookie', cookie) + } + + return finalResponse } } @@ -258,7 +268,8 @@ async function tryRefreshToken( refreshToken: refreshToken, } - const cookies = createAuthCookies(tokens, true) + const remember = isPersistentSession(request) + const cookies = createAuthCookies(tokens, remember) return { success: true, tokens, cookies } } catch { @@ -988,7 +999,7 @@ export async function getErrorOverviewServer( // MARK: Feature Flags API (Deferred Loading) // ============================================================================ -interface TargetingRule { +export interface TargetingRule { column: string filter: string isExclusive: boolean @@ -1182,34 +1193,8 @@ export async function getGoalChartServer( // MARK: Alerts API (Deferred Loading) // ============================================================================ -type QueryMetric = 'page_views' | 'unique_page_views' | 'online_users' | 'custom_events' | 'errors' -type QueryCondition = 'greater_than' | 'greater_equal_than' | 'less_than' | 'less_equal_than' -type QueryTime = - | 'last_15_minutes' - | 'last_30_minutes' - | 'last_1_hour' - | 'last_4_hours' - | 'last_24_hours' - | 'last_48_hours' - -interface Alert { - id: string - name: string - active: boolean - queryMetric: QueryMetric - queryCondition: QueryCondition | null - queryValue: number | null - queryTime: QueryTime | null - queryCustomEvent: string | null - lastTriggered: string | null - alertOnNewErrorsOnly: boolean - alertOnEveryCustomEvent: boolean - pid: string - created: string -} - export interface AlertsResponse { - results: Alert[] + results: Alerts[] total: number } diff --git a/web/app/hooks/useAnnotations.ts b/web/app/hooks/useAnnotations.ts index 5c5f21e1d..48b34392a 100644 --- a/web/app/hooks/useAnnotations.ts +++ b/web/app/hooks/useAnnotations.ts @@ -178,6 +178,8 @@ export const useAnnotations = (): UseAnnotationsReturn => { setAnnotationsLoading(false) if (loadFetcher.data.success && loadFetcher.data.data) { setAnnotations((loadFetcher.data.data as Annotation[]) || []) + } else if (loadFetcher.data.error) { + toast.error(loadFetcher.data.error) } } }, [loadFetcher.state, loadFetcher.data]) @@ -193,13 +195,14 @@ export const useAnnotations = (): UseAnnotationsReturn => { toast.success(t('apiNotifications.annotationDeleted')) } loadAnnotations() + closeAnnotationModal() } else if (fetcher.data.error) { toast.error(fetcher.data.error) } setPendingAction(null) setAnnotationToEdit(undefined) } - }, [fetcher.state, fetcher.data, pendingAction, loadAnnotations, t]) + }, [fetcher.state, fetcher.data, pendingAction, loadAnnotations, t, closeAnnotationModal]) const onAnnotationCreate = useCallback( (date: string, text: string) => { diff --git a/web/app/lib/models/Alerts.ts b/web/app/lib/models/Alerts.ts index cc283dcd8..74dd685ad 100644 --- a/web/app/lib/models/Alerts.ts +++ b/web/app/lib/models/Alerts.ts @@ -13,4 +13,5 @@ export interface Alerts { lastTriggered: string | null alertOnNewErrorsOnly?: boolean alertOnEveryCustomEvent?: boolean + created?: string } diff --git a/web/app/modals/AnnotationModal.tsx b/web/app/modals/AnnotationModal.tsx index 3050025ec..11c69a335 100644 --- a/web/app/modals/AnnotationModal.tsx +++ b/web/app/modals/AnnotationModal.tsx @@ -63,7 +63,6 @@ const AnnotationModal = ({ } onSubmit(date, text.trim()) - _onClose() } const _onDelete = () => { @@ -72,7 +71,6 @@ const AnnotationModal = ({ } onDelete() - _onClose() } const isEditMode = !!annotation diff --git a/web/app/pages/Onboarding/Onboarding.tsx b/web/app/pages/Onboarding/Onboarding.tsx index 17b1660ce..e800d5c1b 100644 --- a/web/app/pages/Onboarding/Onboarding.tsx +++ b/web/app/pages/Onboarding/Onboarding.tsx @@ -169,6 +169,8 @@ const Onboarding = () => { }, [isWaitingForEvents, project, authMe]) const updateUserStep = (step: string) => { + if (fetcher.state !== 'idle') return + const formData = new FormData() formData.set('intent', 'update-step') formData.set('step', step) @@ -179,6 +181,8 @@ const Onboarding = () => { e?.preventDefault() e?.stopPropagation() + if (fetcher.state !== 'idle') return + const trimmedName = projectName.trim() if (!trimmedName) { @@ -199,6 +203,8 @@ const Onboarding = () => { } const handleCompleteOnboarding = (skipped: boolean) => { + if (fetcher.state !== 'idle') return + trackCustom('ONBOARDING_COMPLETED', { skipped }) const formData = new FormData() @@ -207,6 +213,8 @@ const Onboarding = () => { } const handleDeleteAccount = () => { + if (fetcher.state !== 'idle') return + const formData = new FormData() formData.set('intent', 'delete-account') fetcher.submit(formData, { method: 'post' }) diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index efd6be158..77ce84f83 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -46,6 +46,13 @@ const OrganisationSettings = () => { name: organisation?.name || '', })) + useEffect(() => { + if (organisation) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setForm({ name: organisation.name || '' }) + } + }, [organisation]) + const isSaving = fetcher.state === 'submitting' const isDeleting = fetcher.state === 'submitting' && fetcher.formData?.get('intent') === 'delete-organisation' diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index a27dd230c..94a770f64 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -445,8 +445,8 @@ const ProjectSettings = () => { ipBlacklist: _isString(result.ipBlacklist) ? result.ipBlacklist : _join(result.ipBlacklist, ', '), origins: _isString(result.origins) ? result.origins : _join(result.origins, ', '), botsProtectionLevel: result.botsProtectionLevel as 'off' | 'basic', - gscPropertyUri: null, - websiteUrl: null, + gscPropertyUri: result.gscPropertyUri || null, + websiteUrl: result.websiteUrl || null, }) } } catch (reason: any) { diff --git a/web/app/pages/Project/tabs/Alerts/ProjectAlertsSettings.tsx b/web/app/pages/Project/tabs/Alerts/ProjectAlertsSettings.tsx index cd086b179..a0a3b508f 100644 --- a/web/app/pages/Project/tabs/Alerts/ProjectAlertsSettings.tsx +++ b/web/app/pages/Project/tabs/Alerts/ProjectAlertsSettings.tsx @@ -240,6 +240,8 @@ const ProjectAlertsSettings = ({ setErrors(allErrors) setValidated(valid) + + return valid } const shouldIncludeQueryFields = @@ -268,7 +270,9 @@ const ProjectAlertsSettings = ({ const handleSubmit = (e: React.FormEvent) => { setBeenSubmitted(true) - if (!validated) { + const isValid = validate() + + if (!isValid) { e.preventDefault() } } diff --git a/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx b/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx index 4104a1a5d..c825f3ceb 100644 --- a/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx +++ b/web/app/pages/Project/tabs/Alerts/ProjectAlertsView.tsx @@ -247,7 +247,7 @@ const ProjectAlertsInner = ({ deferredData }: ProjectAlertsInnerProps) => { // Track if we're in pagination mode const [isSearchMode, setIsSearchMode] = useState(false) const [total, setTotal] = useState(() => deferredData.alertsData?.total || 0) - const [alerts, setAlerts] = useState(() => (deferredData.alertsData?.results as Alerts[]) || []) + const [alerts, setAlerts] = useState(() => deferredData.alertsData?.results || []) const [page, setPage] = useState(1) const [error, setError] = useState(null) @@ -277,7 +277,7 @@ const ProjectAlertsInner = ({ deferredData }: ProjectAlertsInnerProps) => { // Sync state when loader provides new data useEffect(() => { if (deferredData.alertsData && revalidator.state === 'idle' && !isSearchMode) { - setAlerts((deferredData.alertsData.results as Alerts[]) || []) + setAlerts(deferredData.alertsData.results || []) setTotal(deferredData.alertsData.total || 0) } }, [revalidator.state, deferredData.alertsData, isSearchMode]) diff --git a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx index 3a548d7f7..334753556 100644 --- a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx +++ b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx @@ -581,10 +581,12 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { const loadAllChatsWithSkip = useCallback( (skip: number) => { + if (allChatsFetcher.state !== 'idle') return + setPendingAllChatsSkip(skip) loadAllChats(skip) }, - [loadAllChats], + [allChatsFetcher.state, loadAllChats], ) const loadChat = useCallback( diff --git a/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx b/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx index cdbb9e811..3254c2013 100644 --- a/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx +++ b/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx @@ -327,7 +327,6 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi const actionFetcher = useFetcher() const [isLoading, setIsLoading] = useState(null) - const isLoadingRef = useRef(false) const isMountedRef = useRef(true) const [total, setTotal] = useState(0) const [experiments, setExperiments] = useState([]) @@ -357,11 +356,6 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi const loadExperiments = useCallback( (take: number, skip: number, showLoading = true, search?: string) => { - if (isLoadingRef.current) { - return - } - isLoadingRef.current = true - if (showLoading) { setIsLoading(true) } @@ -382,7 +376,6 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi // Handle list fetcher response useEffect(() => { if (listFetcher.data?.intent === 'get-project-experiments') { - isLoadingRef.current = false if (isMountedRef.current) { setIsLoading(false) if (listFetcher.data.success && listFetcher.data.data) { diff --git a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx index 75189d5a0..acce7632c 100644 --- a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx +++ b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx @@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next' import { useFetcher } from 'react-router' import { toast } from 'sonner' +import type { ProjectFeatureFlag, TargetingRule } from '~/api/api.server' import { useFiltersProxy } from '~/hooks/useAnalyticsProxy' import { API_URL, isSelfhosted } from '~/lib/constants' import { useTheme } from '~/providers/ThemeProvider' @@ -44,24 +45,6 @@ const TARGETING_COLUMNS = [ { value: 'os', label: 'OS' }, ] -interface TargetingRule { - column: string - filter: string - isExclusive: boolean -} - -interface ProjectFeatureFlag { - id: string - key: string - description: string | null - flagType: 'boolean' | 'rollout' - rolloutPercentage: number - targetingRules: TargetingRule[] | null - enabled: boolean - pid: string - created: string -} - interface FeatureFlagSettingsModalProps { isOpen: boolean onClose: () => void @@ -111,15 +94,16 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI const fetchFilterValues = useCallback( async (column: string) => { - if (filterValuesCache[column] || loadingColumns.has(column)) return + const cacheKey = `${projectId}-${column}` + if (filterValuesCache[cacheKey] || loadingColumns.has(column)) return setLoadingColumns((prev) => new Set(prev).add(column)) try { const result = await fetchFilters(projectId, column) - setFilterValuesCache((prev) => ({ ...prev, [column]: result || [] })) + setFilterValuesCache((prev) => ({ ...prev, [cacheKey]: result || [] })) } catch (error) { console.error('Failed to fetch filter values:', error) - setFilterValuesCache((prev) => ({ ...prev, [column]: [] })) + setFilterValuesCache((prev) => ({ ...prev, [cacheKey]: [] })) } finally { setLoadingColumns((prev) => { const newSet = new Set(prev) @@ -135,12 +119,13 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI useEffect(() => { if (isOpen) { TARGETING_COLUMNS.forEach(({ value: column }) => { - if (!filterValuesCache[column] && !loadingColumns.has(column)) { + const cacheKey = `${projectId}-${column}` + if (!filterValuesCache[cacheKey] && !loadingColumns.has(column)) { fetchFilterValues(column) } }) } - }, [isOpen, fetchFilterValues, filterValuesCache, loadingColumns]) + }, [isOpen, fetchFilterValues, filterValuesCache, loadingColumns, projectId]) // Load flag via fetcher useEffect(() => { @@ -155,14 +140,19 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI // Handle fetcher responses useEffect(() => { - if (!fetcher.data || fetcher.state !== 'idle') return + if (fetcher.state !== 'idle') return + + if (isLoading) { + setIsLoading(false) + } + + if (!fetcher.data) return const responseKey = `${fetcher.data.intent}-${fetcher.data.success}` if (processedRef.current === responseKey) return processedRef.current = responseKey if (fetcher.data.intent === 'get-feature-flag') { - setIsLoading(false) if (fetcher.data.success && fetcher.data.data) { const flag = fetcher.data.data as ProjectFeatureFlag setKey(flag.key) @@ -192,7 +182,7 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI toast.error(fetcher.data.error) } } - }, [fetcher.data, fetcher.state, t, onSuccess, onClose]) + }, [fetcher.data, fetcher.state, t, onSuccess, onClose, isLoading]) // Reset processed ref when modal opens useEffect(() => { @@ -245,11 +235,12 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI // Reset filter value when column changes updated[index] = { ...updated[index], column: value as string, filter: '' } // Fetch filter values for the new column if not already cached - if (value && !filterValuesCache[value as string] && !loadingColumns.has(value as string)) { + const cacheKey = `${projectId}-${value as string}` + if (value && !filterValuesCache[cacheKey] && !loadingColumns.has(value as string)) { fetchFilterValues(value as string) } } else { - // @ts-expect-error - TypeScript doesn't like dynamic field assignment + // @ts-ignore - TypeScript doesn't like dynamic field assignment updated[index][field] = value } setTargetingRules(updated) @@ -567,7 +558,7 @@ const { flags } = await response.json() {/* Value Input with Autocomplete */} updateTargetingRule(index, 'filter', value)} placeholder={t('featureFlags.valuePlaceholder')} diff --git a/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx b/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx index 87a7f3c66..311c8dbb7 100644 --- a/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx +++ b/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx @@ -70,7 +70,7 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { return } - const requestId = profilesRequestIdRef.current + const requestId = ++profilesRequestIdRef.current setProfilesLoading(true) try { diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index eddad76f2..5ac26ca6c 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -10,7 +10,7 @@ import _keys from 'lodash/keys' import _map from 'lodash/map' import _size from 'lodash/size' import { MessageSquareTextIcon, MonitorIcon, UserRoundIcon } from 'lucide-react' -import React, { useState, useEffect, memo, useMemo } from 'react' +import React, { useState, useEffect, memo, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useFetcher } from 'react-router' import { ClientOnly } from 'remix-utils/client-only' @@ -123,6 +123,9 @@ const UserSettings = () => { const translatedTimeFormat = _map(TimeFormat, (key) => t(`profileSettings.${key}`)) const [deletionFeedback, setDeletionFeedback] = useState('') + const lastHandledData = useRef(null) + const passwordChangedRef = useRef(false) + const isSubmitting = fetcher.state === 'submitting' const tabs = getTabs(t) @@ -130,7 +133,11 @@ const UserSettings = () => { // Handle fetcher responses useEffect(() => { - if (fetcher.data?.success) { + if (fetcher.state !== 'idle' || !fetcher.data) return + if (lastHandledData.current === fetcher.data) return + lastHandledData.current = fetcher.data + + if (fetcher.data.success) { const { intent, user: updatedUser, apiKey } = fetcher.data if (intent === 'update-profile' && updatedUser) { @@ -138,7 +145,8 @@ const UserSettings = () => { toast.success(t('profileSettings.updated')) // If password was changed, log out the user - if (form.password) { + if (passwordChangedRef.current) { + passwordChangedRef.current = false logout() } } else if (intent === 'generate-api-key' && apiKey) { @@ -163,7 +171,7 @@ const UserSettings = () => { } else if (fetcher.data?.error) { toast.error(fetcher.data.error) } - }, [fetcher.data, mergeUser, t, logout, navigate, form.password]) + }, [fetcher.data, fetcher.state, mergeUser, t, logout, navigate]) const validate = () => { const allErrors = {} as Record @@ -235,6 +243,7 @@ const UserSettings = () => { } if (additionalData?.reportFrequency) formData.set('reportFrequency', additionalData.reportFrequency as string) + passwordChangedRef.current = !!form.password fetcher.submit(formData, { method: 'post' }) } diff --git a/web/app/providers/CurrentProjectProvider.tsx b/web/app/providers/CurrentProjectProvider.tsx index 98ea3bd38..10d9a28ad 100644 --- a/web/app/providers/CurrentProjectProvider.tsx +++ b/web/app/providers/CurrentProjectProvider.tsx @@ -57,6 +57,8 @@ const useProject = (id: string) => { const passwordFetcher = useFetcher() const lastHandledFetcherData = useRef(null) const hasTriedStoredPassword = useRef(false) + const passwordResolverRef = useRef<((value: { success: boolean; error?: string }) => void) | null>(null) + const lastSubmittedPasswordRef = useRef(null) useEffect(() => { if (loaderData?.error && !loaderData.project && !loaderData.isPasswordRequired) { @@ -95,18 +97,34 @@ const useProject = (id: string) => { const result = passwordFetcher.data.data as Project setProject(result) setIsPasswordRequired(false) + + if (lastSubmittedPasswordRef.current) { + setProjectPassword(id, lastSubmittedPasswordRef.current) + lastSubmittedPasswordRef.current = null + } + + if (passwordResolverRef.current) { + passwordResolverRef.current({ success: true }) + passwordResolverRef.current = null + } } else if (passwordFetcher.data.error) { toast.error(t('apiNotifications.incorrectPassword')) + + lastSubmittedPasswordRef.current = null + if (passwordResolverRef.current) { + passwordResolverRef.current({ success: false, error: passwordFetcher.data.error }) + passwordResolverRef.current = null + } } - }, [passwordFetcher.state, passwordFetcher.data, t]) + }, [passwordFetcher.state, passwordFetcher.data, t, id]) const submitPassword = useCallback( async (password: string): Promise<{ success: boolean; error?: string }> => { - setProjectPassword(id, password) - - passwordFetcher.submit({ intent: 'get-project', password }, { method: 'POST', action: `/projects/${id}` }) - - return { success: true } + return new Promise((resolve) => { + passwordResolverRef.current = resolve + lastSubmittedPasswordRef.current = password + passwordFetcher.submit({ intent: 'get-project', password }, { method: 'POST', action: `/projects/${id}` }) + }) }, [id, passwordFetcher], ) @@ -160,9 +178,13 @@ const useLiveVisitors = (project: Project | null) => { return } - const result = await fetchLiveVisitors([projectId]) - if (result) { - setLiveVisitors(result[projectId] || 0) + try { + const result = await fetchLiveVisitors([projectId]) + if (result) { + setLiveVisitors(result[projectId] || 0) + } + } catch (reason) { + console.error('Failed to update live visitors:', reason) } }, [projectId, isLocked, fetchLiveVisitors]) diff --git a/web/app/routes/recovery.tsx b/web/app/routes/recovery.tsx index 4ba78fc8b..69ccba1c3 100644 --- a/web/app/routes/recovery.tsx +++ b/web/app/routes/recovery.tsx @@ -1,8 +1,8 @@ -import type { ActionFunctionArgs, HeadersFunction } from 'react-router' +import type { ActionFunctionArgs, HeadersFunction, LoaderFunctionArgs } from 'react-router' import { data, redirect } from 'react-router' import { SitemapFunction } from 'remix-sitemap' -import { serverFetch } from '~/api/api.server' +import { getAuthenticatedUser, serverFetch } from '~/api/api.server' import ForgotPassword from '~/pages/Auth/ForgotPassword' import { isValidEmail } from '~/utils/validator' @@ -15,6 +15,16 @@ export const headers: HeadersFunction = ({ parentHeaders }) => { return parentHeaders } +export async function loader({ request }: LoaderFunctionArgs) { + const authResult = await getAuthenticatedUser(request) + + if (authResult) { + return redirect('/dashboard') + } + + return null +} + export interface ForgotPasswordActionData { success?: boolean error?: string diff --git a/web/app/utils/session.server.ts b/web/app/utils/session.server.ts index f489aff32..f74cc8176 100644 --- a/web/app/utils/session.server.ts +++ b/web/app/utils/session.server.ts @@ -56,6 +56,11 @@ export function getRefreshToken(request: Request): string | null { return cookies[REFRESH_TOKEN_COOKIE] || null } +export function isPersistentSession(request: Request): boolean { + const cookies = parseCookies(request) + return cookies['is_persistent'] === 'true' +} + export function hasAuthTokens(request: Request): boolean { return !!getAccessToken(request) || !!getRefreshToken(request) } @@ -120,10 +125,16 @@ function buildCookieHeader( export function createAuthCookies(tokens: AuthTokens, remember: boolean): string[] { const maxAge = remember ? PERSISTENT_COOKIE_MAX_AGE : undefined - return [ + const cookies = [ buildCookieHeader(ACCESS_TOKEN_COOKIE, tokens.accessToken, { maxAge }), buildCookieHeader(REFRESH_TOKEN_COOKIE, tokens.refreshToken, { maxAge }), ] + + if (remember) { + cookies.push(buildCookieHeader('is_persistent', 'true', { maxAge: PERSISTENT_COOKIE_MAX_AGE })) + } + + return cookies } /** From 980fa282f13613f416f2f23615b45f37da07b446 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 19:53:50 +0000 Subject: [PATCH 33/57] more code hardening --- web/app/api/api.server.ts | 26 ++++++++++++++----- web/app/hooks/useAnnotations.ts | 12 ++++----- web/app/lib/models/Alerts.ts | 2 +- web/app/pages/Onboarding/Onboarding.tsx | 13 +++++++--- .../pages/Organisations/Settings/index.tsx | 18 ++++++------- .../Project/Settings/ProjectSettings.tsx | 14 +++++----- .../tabs/Alerts/ProjectAlertsSettings.tsx | 2 -- .../pages/Project/tabs/AskAI/AskAIView.tsx | 2 +- .../tabs/Experiments/ExperimentsView.tsx | 8 ++++-- .../FeatureFlags/FeatureFlagSettingsModal.tsx | 15 +++++------ .../tabs/FeatureFlags/FeatureFlagsView.tsx | 3 ++- .../Project/tabs/Profiles/ProfilesView.tsx | 2 +- web/app/pages/UserSettings/UserSettings.tsx | 25 +++++++++++++++++- .../pages/UserSettings/components/TwoFA.tsx | 2 +- web/app/routes/projects.settings.$id.tsx | 5 ++-- 15 files changed, 94 insertions(+), 55 deletions(-) diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 8a599b414..f86c8ec45 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -51,6 +51,16 @@ function getClientIP(request: Request): string | null { return null } +async function fetchWithTimeout( + input: string | URL, + init: Parameters[1] = {}, + timeoutMs = 15000, +): Promise { + const controller = new AbortController() + const id = setTimeout(() => controller.abort(), timeoutMs) + return fetch(input, { ...init, signal: controller.signal }).finally(() => clearTimeout(id)) +} + interface ServerFetchOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' body?: Record @@ -96,7 +106,7 @@ export async function serverFetch( } try { - const response = await fetch(url, { + const response = await fetchWithTimeout(url, { method, headers: fetchHeaders, body: body ? JSON.stringify(body) : undefined, @@ -108,7 +118,7 @@ export async function serverFetch( if (refreshResult.success && refreshResult.tokens) { fetchHeaders['Authorization'] = `Bearer ${refreshResult.tokens.accessToken}` - const retryResponse = await fetch(url, { + const retryResponse = await fetchWithTimeout(url, { method, headers: fetchHeaders, body: body ? JSON.stringify(body) : undefined, @@ -184,7 +194,7 @@ export async function streamingServerFetch( endpoint: string, options: ServerFetchOptions = {}, ): Promise { - const { method = 'GET', body, headers: customHeaders = {} } = options + const { method = 'GET', body, headers: customHeaders = {}, skipAuth = false } = options const apiUrl = getApiUrl() const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint @@ -200,9 +210,11 @@ export async function streamingServerFetch( fetchHeaders['X-Client-IP-Address'] = clientIP } - const accessToken = getAccessToken(request) - if (accessToken) { - fetchHeaders['Authorization'] = `Bearer ${accessToken}` + if (!skipAuth) { + const accessToken = getAccessToken(request) + if (accessToken) { + fetchHeaders['Authorization'] = `Bearer ${accessToken}` + } } const response = await fetch(url, { @@ -212,7 +224,7 @@ export async function streamingServerFetch( }) // If 401/403, try to refresh the token and retry - if (response.status === 401 || response.status === 403) { + if ((response.status === 401 || response.status === 403) && !skipAuth) { const refreshResult = await tryRefreshToken(request) if (refreshResult.success && refreshResult.tokens) { diff --git a/web/app/hooks/useAnnotations.ts b/web/app/hooks/useAnnotations.ts index 48b34392a..b10bceee7 100644 --- a/web/app/hooks/useAnnotations.ts +++ b/web/app/hooks/useAnnotations.ts @@ -184,6 +184,12 @@ export const useAnnotations = (): UseAnnotationsReturn => { } }, [loadFetcher.state, loadFetcher.data]) + const closeAnnotationModal = useCallback(() => { + setIsAnnotationModalOpen(false) + setAnnotationToEdit(undefined) + setAnnotationModalDate(undefined) + }, []) + useEffect(() => { if (fetcher.state === 'idle' && fetcher.data && pendingAction) { if (fetcher.data.success) { @@ -263,12 +269,6 @@ export const useAnnotations = (): UseAnnotationsReturn => { setIsAnnotationModalOpen(true) }, []) - const closeAnnotationModal = useCallback(() => { - setIsAnnotationModalOpen(false) - setAnnotationToEdit(undefined) - setAnnotationModalDate(undefined) - }, []) - const handleChartContextMenu = useCallback( (event: React.MouseEvent, xAxisData: string[] | undefined) => { event.preventDefault() diff --git a/web/app/lib/models/Alerts.ts b/web/app/lib/models/Alerts.ts index 74dd685ad..8538ca59d 100644 --- a/web/app/lib/models/Alerts.ts +++ b/web/app/lib/models/Alerts.ts @@ -13,5 +13,5 @@ export interface Alerts { lastTriggered: string | null alertOnNewErrorsOnly?: boolean alertOnEveryCustomEvent?: boolean - created?: string + created: string } diff --git a/web/app/pages/Onboarding/Onboarding.tsx b/web/app/pages/Onboarding/Onboarding.tsx index e800d5c1b..df9dfee9f 100644 --- a/web/app/pages/Onboarding/Onboarding.tsx +++ b/web/app/pages/Onboarding/Onboarding.tsx @@ -2,7 +2,7 @@ import { EnvelopeIcon } from '@heroicons/react/24/outline' import { CheckCircleIcon as CheckCircleIconSolid } from '@heroicons/react/24/solid' import type { TFunction } from 'i18next' import { ChevronRightIcon, LaptopMinimalIcon, RocketIcon, CodeIcon, MailCheckIcon, SparklesIcon } from 'lucide-react' -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { Trans, useTranslation } from 'react-i18next' import { Link, useNavigate, useFetcher, useLoaderData } from 'react-router' import { toast } from 'sonner' @@ -73,6 +73,7 @@ const Onboarding = () => { const { authMe } = useAuthProxy() const navigate = useNavigate() const fetcher = useFetcher() + const lastHandledFetcherDataRef = useRef(null) const [currentStep, setCurrentStep] = useState(0) const [projectName, setProjectName] = useState('') @@ -84,10 +85,13 @@ const Onboarding = () => { name?: string }>({}) - const isLoading = fetcher.state === 'submitting' + const isLoading = fetcher.state === 'submitting' || fetcher.state === 'loading' - // Handle fetcher responses useEffect(() => { + if (!fetcher.data) return + if (lastHandledFetcherDataRef.current === fetcher.data) return + lastHandledFetcherDataRef.current = fetcher.data + if (fetcher.data?.success) { const { intent, project: newProject } = fetcher.data @@ -113,7 +117,8 @@ const Onboarding = () => { } else if (fetcher.data?.fieldErrors?.name) { setTimeout(() => setNewProjectErrors({ name: fetcher.data!.fieldErrors!.name }), 0) } - }, [fetcher.data, loadUser, logout, navigate, fetcher, t]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetcher.data, fetcher.submit, loadUser, logout, navigate]) useEffect(() => { if (user?.hasCompletedOnboarding) { diff --git a/web/app/pages/Organisations/Settings/index.tsx b/web/app/pages/Organisations/Settings/index.tsx index 77ce84f83..2b5f07dc6 100644 --- a/web/app/pages/Organisations/Settings/index.tsx +++ b/web/app/pages/Organisations/Settings/index.tsx @@ -53,7 +53,7 @@ const OrganisationSettings = () => { } }, [organisation]) - const isSaving = fetcher.state === 'submitting' + const isSaving = fetcher.state === 'submitting' && fetcher.formData?.get('intent') === 'update-organisation' const isDeleting = fetcher.state === 'submitting' && fetcher.formData?.get('intent') === 'delete-organisation' /* eslint-disable react-hooks/set-state-in-effect */ @@ -165,14 +165,6 @@ const OrganisationSettings = () => { document.title = `${t('project.settings.settings')} ${form.name} ${TITLE_SUFFIX}` }, [form, t]) - if (!organisation) { - return ( -
- -
- ) - } - if (error) { return ( { ) } + if (!organisation) { + return ( +
+ +
+ ) + } + return (
diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index 94a770f64..c6eb266ca 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -299,7 +299,7 @@ const ProjectSettings = () => { const [showDelete, setShowDelete] = useState(false) const [showReset, setShowReset] = useState(false) const [isDeleting, setIsDeleting] = useState(false) - const [setResetting, setIsResetting] = useState(false) + const [isResetting, setIsResetting] = useState(false) const [isSaving, setIsSaving] = useState(false) const [showTransfer, setShowTransfer] = useState(false) const [transferEmail, setTransferEmail] = useState('') @@ -420,7 +420,7 @@ const ProjectSettings = () => { } const sharableLink = useMemo(() => { - const origin = requestOrigin || isBrowser ? window.location.origin : 'https://swetrix.com' + const origin = requestOrigin ?? (isBrowser ? window.location.origin : 'https://swetrix.com') return `${origin}/projects/${id}` }, [requestOrigin, id]) @@ -616,7 +616,7 @@ const ProjectSettings = () => { fetcher.submit(formData, { method: 'post' }) } - const onReset = (resetTab: string, dateRange?: Date[], filterType?: string, filterValue?: string) => { + const onReset = (resetTab: string, dateRange?: Date[], filterType?: string, filterValue?: string[]) => { if (fetcher.state === 'submitting') return setIsResetting(true) @@ -628,10 +628,10 @@ const ProjectSettings = () => { formData.set('intent', 'delete-partially') formData.set('from', dateRange[0]?.toISOString() || '') formData.set('to', dateRange[1]?.toISOString() || '') - } else if (resetTab === 'viaFilters' && filterType && filterValue) { + } else if (resetTab === 'viaFilters' && filterType && filterValue && filterValue.length > 0) { formData.set('intent', 'reset-filters') formData.set('type', filterType) - formData.set('value', filterValue) + formData.set('value', JSON.stringify(filterValue)) } fetcher.submit(formData, { method: 'post' }) @@ -671,7 +671,7 @@ const ProjectSettings = () => { toast.error(t('project.settings.noFilters')) return } - onReset('viaFilters', undefined, filterType, activeFilter[0]) + onReset('viaFilters', undefined, filterType, activeFilter) } else { onReset('all') } @@ -1138,7 +1138,7 @@ const ProjectSettings = () => { setShowReset={setShowReset} setShowDelete={setShowDelete} isDeleting={isDeleting} - setResetting={setResetting} + setResetting={isResetting} /> ) : null} diff --git a/web/app/pages/Project/tabs/Alerts/ProjectAlertsSettings.tsx b/web/app/pages/Project/tabs/Alerts/ProjectAlertsSettings.tsx index a0a3b508f..75f88581e 100644 --- a/web/app/pages/Project/tabs/Alerts/ProjectAlertsSettings.tsx +++ b/web/app/pages/Project/tabs/Alerts/ProjectAlertsSettings.tsx @@ -63,7 +63,6 @@ const ProjectAlertsSettings = ({ alertOnNewErrorsOnly: true, alertOnEveryCustomEvent: false, }) - const [validated, setValidated] = useState(false) const [errors, setErrors] = useState>({}) const [beenSubmitted, setBeenSubmitted] = useState(false) const [showModal, setShowModal] = useState(false) @@ -239,7 +238,6 @@ const ProjectAlertsSettings = ({ const valid = _isEmpty(_keys(allErrors)) setErrors(allErrors) - setValidated(valid) return valid } diff --git a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx index 334753556..5c8463075 100644 --- a/web/app/pages/Project/tabs/AskAI/AskAIView.tsx +++ b/web/app/pages/Project/tabs/AskAI/AskAIView.tsx @@ -908,7 +908,7 @@ const AskAIView = ({ projectId }: AskAIViewProps) => { streamingPartsRef.current.push({ type: 'text', text: currentTextPartRef.current }) } - if (finalContent.trim()) { + if (finalContent.trim() || streamingToolCallsRef.current.length > 0) { const assistantMessage: Message = { id: generateMessageId(), role: 'assistant', diff --git a/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx b/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx index 3254c2013..fc879b351 100644 --- a/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx +++ b/web/app/pages/Project/tabs/Experiments/ExperimentsView.tsx @@ -370,7 +370,8 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi { method: 'POST' }, ) }, - [listFetcher], + // eslint-disable-next-line react-hooks/exhaustive-deps + [listFetcher.submit], ) // Handle list fetcher response @@ -394,7 +395,10 @@ const ExperimentsView = ({ period, from = '', to = '', timezone }: ExperimentsVi useEffect(() => { if (!actionFetcher.data || actionFetcher.state !== 'idle') return - const actionKey = `${actionFetcher.data.intent}-${JSON.stringify(actionFetcher.data.data)}` + const { intent, success, error, data } = actionFetcher.data + const responseData = actionFetcher.data as Record + const uniqueSuffix = responseData.requestId ?? responseData.timestamp ?? actionFetcher.state + const actionKey = `${intent}-${success ?? false}-${error ?? ''}-${uniqueSuffix}-${JSON.stringify(data)}` if (processedActionRef.current === actionKey) return processedActionRef.current = actionKey diff --git a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx index acce7632c..1a146c6ee 100644 --- a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx +++ b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagSettingsModal.tsx @@ -61,7 +61,7 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI const { theme } = useTheme() const isNew = !flagId const fetcher = useFetcher() - const processedRef = useRef(null) + const lastHandledData = useRef(null) const { fetchFilters } = useFiltersProxy() const [isLoading, setIsLoading] = useState(false) @@ -138,7 +138,6 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, flagId]) - // Handle fetcher responses useEffect(() => { if (fetcher.state !== 'idle') return @@ -147,10 +146,8 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI } if (!fetcher.data) return - - const responseKey = `${fetcher.data.intent}-${fetcher.data.success}` - if (processedRef.current === responseKey) return - processedRef.current = responseKey + if (lastHandledData.current === fetcher.data) return + lastHandledData.current = fetcher.data if (fetcher.data.intent === 'get-feature-flag') { if (fetcher.data.success && fetcher.data.data) { @@ -184,16 +181,16 @@ const FeatureFlagSettingsModal = ({ isOpen, onClose, onSuccess, projectId, flagI } }, [fetcher.data, fetcher.state, t, onSuccess, onClose, isLoading]) - // Reset processed ref when modal opens + // Reset ref when modal opens to allow fresh data handling useEffect(() => { if (isOpen) { - processedRef.current = null + lastHandledData.current = null } }, [isOpen]) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - processedRef.current = null + lastHandledData.current = null if (isNew) { fetcher.submit( diff --git a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx index b52e6d7d6..24f607fc7 100644 --- a/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx +++ b/web/app/pages/Project/tabs/FeatureFlags/FeatureFlagsView.tsx @@ -515,7 +515,8 @@ const FeatureFlagsViewInner = ({ period, from = '', to = '', timezone, deferredD { method: 'POST' }, ) }, - [listFetcher], + // eslint-disable-next-line react-hooks/exhaustive-deps + [listFetcher.submit], ) // Handle list fetcher response diff --git a/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx b/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx index 311c8dbb7..70d1e651a 100644 --- a/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx +++ b/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx @@ -95,7 +95,7 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { }) if (requestId === profilesRequestIdRef.current && isMountedRef.current) { - const profilesList = (dataProfiles?.profiles || []) as unknown as Profile[] + const profilesList = dataProfiles?.profiles || [] if (override) { setProfiles(profilesList) } else { diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index 5ac26ca6c..10892d723 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -125,13 +125,13 @@ const UserSettings = () => { const lastHandledData = useRef(null) const passwordChangedRef = useRef(false) + const pendingToggleRef = useRef<{ intent: string; previousValue: boolean } | null>(null) const isSubmitting = fetcher.state === 'submitting' const tabs = getTabs(t) const activeTabLabel = useMemo(() => _find(tabs, (tab) => tab.id === activeTab)?.label, [tabs, activeTab]) - // Handle fetcher responses useEffect(() => { if (fetcher.state !== 'idle' || !fetcher.data) return if (lastHandledData.current === fetcher.data) return @@ -156,9 +156,11 @@ const UserSettings = () => { mergeUser({ apiKey: null }) toast.success(t('profileSettings.updated')) } else if (intent === 'toggle-live-visitors' && updatedUser) { + pendingToggleRef.current = null mergeUser(updatedUser) toast.success(t('profileSettings.updated')) } else if (intent === 'toggle-login-notifications') { + pendingToggleRef.current = null toast.success(t('profileSettings.updated')) } else if (intent === 'confirm-email') { setCookie(CONFIRMATION_TIMEOUT, true, 600) @@ -169,6 +171,15 @@ const UserSettings = () => { navigate(routes.main) } } else if (fetcher.data?.error) { + if (pendingToggleRef.current) { + const { intent, previousValue } = pendingToggleRef.current + if (intent === 'toggle-live-visitors') { + mergeUser({ showLiveVisitorsInTitle: previousValue }) + } else if (intent === 'toggle-login-notifications') { + mergeUser({ receiveLoginNotifications: previousValue }) + } + pendingToggleRef.current = null + } toast.error(fetcher.data.error) } }, [fetcher.data, fetcher.state, mergeUser, t, logout, navigate]) @@ -270,6 +281,10 @@ const UserSettings = () => { } const handleShowLiveVisitorsSave = (checked: boolean) => { + pendingToggleRef.current = { + intent: 'toggle-live-visitors', + previousValue: user?.showLiveVisitorsInTitle ?? false, + } const formData = new FormData() formData.set('intent', 'toggle-live-visitors') formData.set('show', checked.toString()) @@ -279,6 +294,10 @@ const UserSettings = () => { } const handleReceiveLoginNotifications = (checked: boolean) => { + pendingToggleRef.current = { + intent: 'toggle-login-notifications', + previousValue: user?.receiveLoginNotifications ?? false, + } const formData = new FormData() formData.set('intent', 'toggle-login-notifications') formData.set('receiveLoginNotifications', checked.toString()) @@ -319,6 +338,10 @@ const UserSettings = () => { } const onApiKeyGenerate = () => { + if (isSubmitting) { + return + } + const formData = new FormData() formData.set('intent', 'generate-api-key') fetcher.submit(formData, { method: 'post' }) diff --git a/web/app/pages/UserSettings/components/TwoFA.tsx b/web/app/pages/UserSettings/components/TwoFA.tsx index b9770c86e..1c2118340 100644 --- a/web/app/pages/UserSettings/components/TwoFA.tsx +++ b/web/app/pages/UserSettings/components/TwoFA.tsx @@ -27,7 +27,7 @@ const TwoFA = () => { const [twoFARecovery, setTwoFARecovery] = useState(null) const { isTwoFactorAuthenticationEnabled } = user || {} - const isLoading = fetcher.state === 'submitting' + const isLoading = fetcher.state === 'submitting' || fetcher.state === 'loading' // Handle fetcher responses useEffect(() => { diff --git a/web/app/routes/projects.settings.$id.tsx b/web/app/routes/projects.settings.$id.tsx index 6e6975b5d..789d41b40 100644 --- a/web/app/routes/projects.settings.$id.tsx +++ b/web/app/routes/projects.settings.$id.tsx @@ -161,11 +161,10 @@ export async function action({ request, params }: ActionFunctionArgs) { case 'reset-filters': { const type = formData.get('type')?.toString() - const value = formData.get('value')?.toString() + const filters = formData.get('value')?.toString() - const result = await serverFetch(request, `project/reset-filters/${id}`, { + const result = await serverFetch(request, `project/reset-filters/${id}?type=${encodeURIComponent(type || '')}&filters=${encodeURIComponent(filters || '')}`, { method: 'DELETE', - body: { type, value }, }) if (result.error) { From b546991cf4ee7dc164e7faac63c687028c53c388 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 19:56:59 +0000 Subject: [PATCH 34/57] fix lint:tsc --- .../pages/Project/Settings/ProjectSettings.tsx | 15 ++++++++++----- web/app/pages/Project/View/Panels.tsx | 2 +- .../pages/Project/tabs/Profiles/ProfilesView.tsx | 2 +- .../pages/Project/tabs/Sessions/SessionsView.tsx | 2 +- web/app/pages/UserSettings/components/TwoFA.tsx | 2 +- web/app/routes/api.analytics.ts | 2 +- web/app/routes/api.auth.ts | 2 +- web/app/routes/onboarding.tsx | 2 +- 8 files changed, 17 insertions(+), 12 deletions(-) diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index c6eb266ca..ddd56349a 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -445,8 +445,9 @@ const ProjectSettings = () => { ipBlacklist: _isString(result.ipBlacklist) ? result.ipBlacklist : _join(result.ipBlacklist, ', '), origins: _isString(result.origins) ? result.origins : _join(result.origins, ', '), botsProtectionLevel: result.botsProtectionLevel as 'off' | 'basic', - gscPropertyUri: result.gscPropertyUri || null, - websiteUrl: result.websiteUrl || null, + countryBlacklist: (result as unknown as Project).countryBlacklist || [], + gscPropertyUri: (result as unknown as Project).gscPropertyUri || null, + websiteUrl: (result as unknown as Project).websiteUrl || null, }) } } catch (reason: any) { @@ -941,7 +942,7 @@ const ProjectSettings = () => { gscFetcher.submit({ intent: 'gsc-connect' }, { method: 'post' }) }} loading={ - gscFetcher.state !== 'idle' ? gscFetcher.formData?.get('intent') === 'gsc-connect' : null + gscFetcher.state !== 'idle' ? gscFetcher.formData?.get('intent') === 'gsc-connect' : undefined } primary regular @@ -968,7 +969,9 @@ const ProjectSettings = () => { gscFetcher.submit({ intent: 'gsc-disconnect' }, { method: 'post' }) }} loading={ - gscFetcher.state !== 'idle' ? gscFetcher.formData?.get('intent') === 'gsc-disconnect' : null + gscFetcher.state !== 'idle' + ? gscFetcher.formData?.get('intent') === 'gsc-disconnect' + : undefined } > {t('common.disconnect')} @@ -1006,7 +1009,9 @@ const ProjectSettings = () => { ) }} loading={ - gscFetcher.state !== 'idle' ? gscFetcher.formData?.get('intent') === 'gsc-set-property' : null + gscFetcher.state !== 'idle' + ? gscFetcher.formData?.get('intent') === 'gsc-set-property' + : undefined } primary regular diff --git a/web/app/pages/Project/View/Panels.tsx b/web/app/pages/Project/View/Panels.tsx index 678097f7d..bbd33ef13 100644 --- a/web/app/pages/Project/View/Panels.tsx +++ b/web/app/pages/Project/View/Panels.tsx @@ -480,7 +480,7 @@ const CustomEvents = ({ customs, chartData, filters, getCustomEventMetadata, get return } - setStackedChart(data?.chart || null) + setStackedChart((data?.chart as unknown as typeof stackedChart) || null) } catch (reason) { console.error('[ERROR](CustomEvents) Failed to load stacked custom events chart', reason) if (!cancelled) { diff --git a/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx b/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx index 70d1e651a..311c8dbb7 100644 --- a/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx +++ b/web/app/pages/Project/tabs/Profiles/ProfilesView.tsx @@ -95,7 +95,7 @@ const ProfilesView = ({ tnMapping }: ProfilesViewProps) => { }) if (requestId === profilesRequestIdRef.current && isMountedRef.current) { - const profilesList = dataProfiles?.profiles || [] + const profilesList = (dataProfiles?.profiles || []) as unknown as Profile[] if (override) { setProfiles(profilesList) } else { diff --git a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx index d9aab3872..dd6e6927e 100644 --- a/web/app/pages/Project/tabs/Sessions/SessionsView.tsx +++ b/web/app/pages/Project/tabs/Sessions/SessionsView.tsx @@ -99,7 +99,7 @@ const SessionsViewInner = ({ tnMapping, rotateXAxis, deferredData }: SessionsVie const activeSession: ActiveSession | null = useMemo(() => { if (deferredData.sessionDetails) { return { - details: deferredData.sessionDetails.details as SessionDetailsType, + details: deferredData.sessionDetails.details as unknown as SessionDetailsType, chart: deferredData.sessionDetails.chart, pages: deferredData.sessionDetails.pages, timeBucket: deferredData.sessionDetails.timeBucket, diff --git a/web/app/pages/UserSettings/components/TwoFA.tsx b/web/app/pages/UserSettings/components/TwoFA.tsx index 1c2118340..b91e05f22 100644 --- a/web/app/pages/UserSettings/components/TwoFA.tsx +++ b/web/app/pages/UserSettings/components/TwoFA.tsx @@ -42,7 +42,7 @@ const TwoFA = () => { } else if (intent === 'enable-2fa' && twoFAData?.twoFactorRecoveryCode) { mergeUser({ isTwoFactorAuthenticationEnabled: true }) setTimeout(() => { - setTwoFARecovery(twoFAData.twoFactorRecoveryCode) + setTwoFARecovery(twoFAData.twoFactorRecoveryCode ?? null) setTwoFACode('') }, 0) } else if (intent === 'disable-2fa') { diff --git a/web/app/routes/api.analytics.ts b/web/app/routes/api.analytics.ts index b4df6dce6..68838aae9 100644 --- a/web/app/routes/api.analytics.ts +++ b/web/app/routes/api.analytics.ts @@ -595,7 +595,7 @@ export async function action({ request }: ActionFunctionArgs) { from: params.from, to: params.to, timezone: params.timezone || 'UTC', - filters: params.filters, + filters: params.filters || [], includeChart: true, }) return data>>({ diff --git a/web/app/routes/api.auth.ts b/web/app/routes/api.auth.ts index 5c01c6995..fffd0e44c 100644 --- a/web/app/routes/api.auth.ts +++ b/web/app/routes/api.auth.ts @@ -71,7 +71,7 @@ export async function action({ request }: ActionFunctionArgs) { error: null, }, { - headers: cookies.map((cookie) => ['Set-Cookie', cookie]), + headers: cookies.map((cookie) => ['Set-Cookie', cookie]) as [string, string][], }, ) } diff --git a/web/app/routes/onboarding.tsx b/web/app/routes/onboarding.tsx index 09dc1887c..878b39fd5 100644 --- a/web/app/routes/onboarding.tsx +++ b/web/app/routes/onboarding.tsx @@ -30,7 +30,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const user = authResult?.user const cookies: string[] = authResult?.cookies || [] - if (user?.onboardingStep === 'setup_tracking' || user?.onboardingStep === 'waiting_for_events') { + if (user?.user?.onboardingStep === 'setup_tracking' || user?.user?.onboardingStep === 'waiting_for_events') { const projectsResult = await serverFetch<{ results: Project[] total: number From 5f0b73bedd55c4d04e93dcb6cfbcde541a7cc2bd Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 19:57:18 +0000 Subject: [PATCH 35/57] format --- web/app/pages/Project/Settings/People/People.tsx | 2 +- web/app/routes/projects.settings.$id.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/web/app/pages/Project/Settings/People/People.tsx b/web/app/pages/Project/Settings/People/People.tsx index 44b99b818..6f601aa6e 100644 --- a/web/app/pages/Project/Settings/People/People.tsx +++ b/web/app/pages/Project/Settings/People/People.tsx @@ -353,7 +353,7 @@ const People = ({ project }: PeopleProps) => { title={t('project.settings.removeUser', { user: memberToRemove?.user?.email })} message={t('project.settings.removeConfirm')} isOpened={showDeleteModal} - isLoading={isSubmitting ? (fetcher.formData?.get('intent') === 'delete-share-user') : undefined} + isLoading={isSubmitting ? fetcher.formData?.get('intent') === 'delete-share-user' : undefined} /> ({ intent, error: result.error as string }, { status: 400 }) From 851e590f3e0b5f4561edf2ddd474518332b30c90 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 10 Jan 2026 21:54:09 +0000 Subject: [PATCH 36/57] add react-scan --- web/app/root.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/app/root.tsx b/web/app/root.tsx index e28a81cdc..64253b9bf 100644 --- a/web/app/root.tsx +++ b/web/app/root.tsx @@ -24,7 +24,7 @@ import { getAuthenticatedUser } from '~/api/api.server' import { LocaleLinks } from '~/components/LocaleLinks' import SelfhostedApiUrlBanner from '~/components/SelfhostedApiUrlBanner' import { SEO } from '~/components/SEO' -import { CONTACT_EMAIL, LS_THEME_SETTING, isSelfhosted, I18N_CACHE_BREAKER } from '~/lib/constants' +import { CONTACT_EMAIL, LS_THEME_SETTING, isSelfhosted, I18N_CACHE_BREAKER, isDevelopment } from '~/lib/constants' import mainCss from '~/styles/index.css?url' import tailwindCss from '~/styles/tailwind.css?url' import { trackViews, trackErrors, trackError } from '~/utils/analytics' @@ -285,6 +285,7 @@ export default function App() { + {isDevelopment ?