Repository: IgnisDa/ryot (language: rust)

Issue:
Documentation improvements 
Would be nice to have some documentation improvements as it’s a bit puzzling at times. In README there is mentioned well documented Graphql but I can’t find anything related to it from the docs. Also it’s not quite clear based on the docs what tracking means, for example, for books as they seemed to work OK out of the box with Google Books but there still is option to add Google Books API but what benefit would it bring?  So basically the word “tracking” can mean so many things for different people, some think of it as tracking the state of in progress so how much you have done and some, like I, think of it as just tracking what have I watched or read in the past. 

So it would be great to have documentation improvements where things would be explained briefly. 


Discussion / hints:
I agree, ryot is confusing to new users. Can you give me a list of what all you'd want to see documented?
Here is a quick list

- This snippet in the Configuration page

`You can specify configuration options via environment variables or via files (loaded from config/ryot.json, config/ryot.toml, config/ryot.yaml). They should be present in /home/ryot/config/ryot.<ext>.`

It is a bit unclear how to use these files. So would be good to open it a bit. Also an example for docker compose .env file would be good as there are so many variables that can be used it would clogg the docker compose yaml easily.

- Config builder link in the configuration page is "404"
- It can be confusing that the parameters list in configuration is in different form than what the environment variables in docker compose so it might be misleading
- Books section and Google Books I mentioned in the first message.
- Pro feature, can't really find an explanation that what is included
- The backend side seems mosly undocumented. I only found mentioning of this backend from the configuration mention of the loaded config. This also includes GraphQL. So would be good to have separate docs page for backend and have all the stuff listed there with examples of GraphQL usage

These are the first things that came to mind when setting things up.

One thing not related to documentation, why is the authentication redirecting to movie search with "avengers" instead of the dashboard?
> One thing not related to documentation, why is the authentication redirecting to movie search with "avengers" instead of the dashboard?

When you do not have any media logged (or your summary is empty), this happens. Its geared towards new users mainly.
Note to self:

-  Document "Admin Access Token"

Fix patch:
diff --git a/README.md b/README.md
index 654b9b9355..5754915c7a 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@ You can also open an issue on GitHub if you find any bugs or have feature reques
   and fitness
 - ✅ Import data from Goodreads, Trakt, Strong App [etc](https://docs.ryot.io/importing.html)
 - ✅ Integration with Jellyfin, Kodi, Plex, Emby, Audiobookshelf [etc](https://docs.ryot.io/integrations.html)
-- ✅ [Supports](https://docs.ryot.io/guides/openid.html) OpenID Connect
+- ✅ [Supports](https://docs.ryot.io/guides/authentication.html) OpenID Connect
 - ✅ Sends notifications to Discord, Ntfy, Apprise etc
 - ✅ Self-hosted
 - ✅ PWA enabled
diff --git a/apps/frontend/app/lib/generals.ts b/apps/frontend/app/lib/generals.ts
index 41100a1a73..b9ee300f3f 100644
--- a/apps/frontend/app/lib/generals.ts
+++ b/apps/frontend/app/lib/generals.ts
@@ -66,12 +66,11 @@ export const emptyDecimalString = z
 	.transform((v) => (!v ? undefined : Number.parseFloat(v).toString()))
 	.nullable();
 
-export const CurrentWorkoutKey = "CurrentWorkout";
 export const LOGO_IMAGE_URL =
 	"https://raw.githubusercontent.com/IgnisDa/ryot/main/libs/assets/icon-512x512.png";
 export const redirectToQueryParam = "redirectTo";
 export const pageQueryParam = "page";
-export const AUTH_COOKIE_NAME = "Auth";
+export const FRONTEND_AUTH_COOKIE_NAME = "Auth";
 export const toastKey = "Toast";
 export const PRO_REQUIRED_MESSAGE = "Ryot pro is required to use this feature";
 
@@ -316,7 +315,7 @@ export const clientGqlService = new GraphQLClient(
 	`${applicationBaseUrl}/backend/graphql`,
 	{
 		headers: () => {
-			const data = Cookies.get(AUTH_COOKIE_NAME);
+			const data = Cookies.get(FRONTEND_AUTH_COOKIE_NAME);
 			return { authorization: data ? `Bearer ${data}` : "" };
 		},
 	},
diff --git a/apps/frontend/app/lib/hooks.ts b/apps/frontend/app/lib/hooks.ts
index 573908919b..0d92d1b172 100644
--- a/apps/frontend/app/lib/hooks.ts
+++ b/apps/frontend/app/lib/hooks.ts
@@ -18,8 +18,7 @@ import { $path } from "remix-routes";
 import invariant from "tiny-invariant";
 import { useInterval } from "usehooks-ts";
 import {
-	CurrentWorkoutKey,
-	type FitnessAction,
+	FitnessAction,
 	dayjsLib,
 	getMetadataDetailsQuery,
 	getStringAsciiValue,
@@ -104,10 +103,6 @@ export const useGetWorkoutStarter = () => {
 
 	const fn = (wkt: InProgressWorkout, action: FitnessAction) => {
 		setCurrentWorkout(wkt);
-		Cookies.set(CurrentWorkoutKey, action, {
-			expires: 2,
-			sameSite: "Strict",
-		});
 		navigate($path("/fitness/:action", { action }));
 		revalidator.revalidate();
 	};
@@ -208,3 +203,13 @@ export const useComplexJsonUpdate = () => {
 
 	return { reset, appendPref, toUpdatePreferences };
 };
+
+export const useIsFitnessActionActive = () => {
+	const [currentWorkout] = useCurrentWorkout();
+	const action = currentWorkout?.currentActionOrCompleted;
+	return (
+		action !== undefined &&
+		action !== true &&
+		[FitnessAction.LogWorkout, FitnessAction.CreateTemplate].includes(action)
+	);
+};
diff --git a/apps/frontend/app/lib/state/fitness.ts b/apps/frontend/app/lib/state/fitness.ts
index a98e404839..3deae901ab 100644
--- a/apps/frontend/app/lib/state/fitness.ts
+++ b/apps/frontend/app/lib/state/fitness.ts
@@ -20,13 +20,12 @@ import { queryOptions } from "@tanstack/react-query";
 import { createDraft, finishDraft } from "immer";
 import { atom, useAtom } from "jotai";
 import { atomWithStorage } from "jotai/utils";
-import Cookies from "js-cookie";
 import { $path } from "remix-routes";
 import { match } from "ts-pattern";
 import { withFragment } from "ufo";
 import { v4 as randomUUID } from "uuid";
 import {
-	CurrentWorkoutKey,
+	type FitnessAction,
 	clientGqlService,
 	dayjsLib,
 	getTimeOfDay,
@@ -80,12 +79,13 @@ export type InProgressWorkout = {
 	exercises: Array<Exercise>;
 	replacingExerciseIdx?: number;
 	updateWorkoutTemplateId?: string;
+	currentActionOrCompleted: FitnessAction | true;
 };
 
 type CurrentWorkout = InProgressWorkout | null;
 
 const currentWorkoutAtom = atomWithStorage<CurrentWorkout>(
-	CurrentWorkoutKey,
+	"CurrentWorkout",
 	null,
 );
 
@@ -101,13 +101,16 @@ export const useGetSetAtIndex = (exerciseIdx: number, setIdx: number) => {
 	return exercise?.sets[setIdx];
 };
 
-export const getDefaultWorkout = (): InProgressWorkout => {
+export const getDefaultWorkout = (
+	fitnessEntity: FitnessAction,
+): InProgressWorkout => {
 	const date = new Date();
 	return {
 		images: [],
 		videos: [],
 		supersets: [],
 		exercises: [],
+		currentActionOrCompleted: fitnessEntity,
 		startTime: date.toISOString(),
 		name: `${getTimeOfDay(date.getHours())} Workout`,
 	};
@@ -259,6 +262,7 @@ export const useMeasurementsDrawerOpen = () =>
 
 export const duplicateOldWorkout = async (
 	name: string,
+	fitnessEntity: FitnessAction,
 	workoutInformation: WorkoutInformation,
 	coreDetails: ReturnType<typeof useCoreDetails>,
 	userFitnessPreferences: UserFitnessPreferences,
@@ -269,7 +273,7 @@ export const duplicateOldWorkout = async (
 		updateWorkoutTemplateId?: string;
 	},
 ) => {
-	const inProgress = getDefaultWorkout();
+	const inProgress = getDefaultWorkout(fitnessEntity);
 	inProgress.name = name;
 	inProgress.repeatedFrom = params.repeatedFromId;
 	inProgress.templateId = params.templateId;
@@ -345,6 +349,7 @@ export const addExerciseToWorkout = async (
 	setCurrentWorkout: (v: InProgressWorkout) => void,
 	selectedExercises: Array<{ name: string; lot: ExerciseLot }>,
 ) => {
+	if (currentWorkout.currentActionOrCompleted === true) return;
 	const draft = createDraft(currentWorkout);
 	const idxOfNextExercise = draft.exercises.length;
 	for (const [_exerciseIdx, ex] of selectedExercises.entries()) {
@@ -390,11 +395,11 @@ export const addExerciseToWorkout = async (
 	}
 	const finishedDraft = finishDraft(draft);
 	setCurrentWorkout(finishedDraft);
-	const currentEntity = Cookies.get(CurrentWorkoutKey);
-	if (!currentEntity) return;
 	navigate(
 		withFragment(
-			$path("/fitness/:action", { action: currentEntity }),
+			$path("/fitness/:action", {
+				action: currentWorkout.currentActionOrCompleted,
+			}),
 			idxOfNextExercise.toString(),
 		),
 	);
diff --git a/apps/frontend/app/lib/utilities.server.ts b/apps/frontend/app/lib/utilities.server.ts
index 0fec7af1a8..deafe350d2 100644
--- a/apps/frontend/app/lib/utilities.server.ts
+++ b/apps/frontend/app/lib/utilities.server.ts
@@ -23,6 +23,7 @@ import {
 	type RequestDocument,
 	type Variables,
 } from "graphql-request";
+import { jwtDecode } from "jwt-decode";
 import type { VariablesAndRequestHeadersArgs } from "node_modules/graphql-request/build/legacy/helpers/types";
 import { $path } from "remix-routes";
 import { match } from "ts-pattern";
@@ -30,9 +31,7 @@ import { withoutHost } from "ufo";
 import { v4 as randomUUID } from "uuid";
 import { z } from "zod";
 import {
-	AUTH_COOKIE_NAME,
-	CurrentWorkoutKey,
-	FitnessAction,
+	FRONTEND_AUTH_COOKIE_NAME,
 	dayjsLib,
 	emptyDecimalString,
 	emptyNumberString,
@@ -103,7 +102,7 @@ export const getCookieValue = (request: Request, cookieName: string) =>
 	parse(request.headers.get("cookie") || "")[cookieName];
 
 export const getAuthorizationCookie = (request: Request) =>
-	getCookieValue(request, AUTH_COOKIE_NAME);
+	getCookieValue(request, FRONTEND_AUTH_COOKIE_NAME);
 
 export const redirectIfNotAuthenticatedOrUpdated = async (request: Request) => {
 	const userDetails = await getCachedUserDetails(request);
@@ -156,6 +155,14 @@ export const MetadataSpecificsSchema = z.object({
 	mangaVolumeNumber: emptyNumberString,
 });
 
+export const getDecodedJwt = (request: Request) => {
+	const token = getAuthorizationCookie(request) ?? "";
+	return jwtDecode<{
+		sub: string;
+		access_link?: { id: string; is_demo?: boolean };
+	}>(token);
+};
+
 export const getCachedCoreDetails = async () => {
 	return await queryClient.ensureQueryData({
 		queryKey: queryFactory.miscellaneous.coreDetails().queryKey,
@@ -165,9 +172,9 @@ export const getCachedCoreDetails = async () => {
 };
 
 const getCachedUserDetails = async (request: Request) => {
-	const token = getAuthorizationCookie(request);
+	const decodedJwt = getDecodedJwt(request);
 	return await queryClient.ensureQueryData({
-		queryKey: queryFactory.users.details(token ?? "").queryKey,
+		queryKey: queryFactory.users.details(decodedJwt.sub).queryKey,
 		queryFn: () =>
 			serverGqlService
 				.authenticatedRequest(request, UserDetailsDocument, undefined)
@@ -349,13 +356,15 @@ export const getCookiesForApplication = async (
 		(tokenValidForDays || coreDetails.tokenValidForDays) * 24 * 60 * 60;
 	const options = { maxAge, path: "/" } satisfies SerializeOptions;
 	return combineHeaders({
-		"set-cookie": serialize(AUTH_COOKIE_NAME, token, options),
+		"set-cookie": serialize(FRONTEND_AUTH_COOKIE_NAME, token, options),
 	});
 };
 
 export const getLogoutCookies = () => {
 	return combineHeaders({
-		"set-cookie": serialize(AUTH_COOKIE_NAME, "", { expires: new Date(0) }),
+		"set-cookie": serialize(FRONTEND_AUTH_COOKIE_NAME, "", {
+			expires: new Date(0),
+		}),
 	});
 };
 
@@ -367,20 +376,6 @@ export const extendResponseHeaders = (
 		responseHeaders.append(key, value);
 };
 
-export const getWorkoutCookieValue = (request: Request) => {
-	return parse(request.headers.get("cookie") || "")[CurrentWorkoutKey];
-};
-
-export const isWorkoutActive = (request: Request) => {
-	const cookieValue = getWorkoutCookieValue(request);
-	const inProgress =
-		cookieValue &&
-		[FitnessAction.LogWorkout, FitnessAction.UpdateWorkout]
-			.map(String)
-			.includes(cookieValue);
-	return inProgress;
-};
-
 export const getEnhancedCookieName = async (path: string, request: Request) => {
 	const userDetails = await redirectIfNotAuthenticatedOrUpdated(request);
 	return `SearchParams__${userDetails.id}__${path}`;
diff --git a/apps/frontend/app/routes/_dashboard.fitness.$action.tsx b/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
index 40cd9728a2..8411937d65 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.$action.tsx
@@ -86,12 +86,12 @@ import {
 	IconReorder,
 	IconReplace,
 	IconTrash,
+	IconZzz,
 } from "@tabler/icons-react";
 import { useQuery } from "@tanstack/react-query";
 import { Howl } from "howler";
 import { produce } from "immer";
 import { RESET } from "jotai/utils";
-import Cookies from "js-cookie";
 import { useEffect, useMemo, useRef, useState } from "react";
 import Webcam from "react-webcam";
 import { $path } from "remix-routes";
@@ -109,7 +109,6 @@ import {
 	displayWeightWithUnit,
 } from "~/components/fitness";
 import {
-	CurrentWorkoutKey,
 	FitnessAction,
 	FitnessEntity,
 	PRO_REQUIRED_MESSAGE,
@@ -146,25 +145,11 @@ import {
 	useMeasurementsDrawerOpen,
 	useTimerAtom,
 } from "~/lib/state/fitness";
-import { isWorkoutActive, redirectWithToast } from "~/lib/utilities.server";
 
-const workoutCookieName = CurrentWorkoutKey;
-
-export const loader = async ({ params, request }: LoaderFunctionArgs) => {
+export const loader = async ({ params }: LoaderFunctionArgs) => {
 	const { action } = zx.parseParams(params, {
 		action: z.nativeEnum(FitnessAction),
 	});
-	await match(action)
-		.with(FitnessAction.LogWorkout, FitnessAction.UpdateWorkout, async () => {
-			const inProgress = isWorkoutActive(request);
-			if (!inProgress)
-				throw await redirectWithToast($path("/"), {
-					type: "error",
-					message: "No workout in progress",
-				});
-		})
-		.with(FitnessAction.CreateTemplate, async () => {})
-		.exhaustive();
 	return {
 		action,
 		isUpdatingWorkout: action === FitnessAction.UpdateWorkout,
@@ -427,6 +412,11 @@ export default function Page() {
 														});
 														return;
 													}
+													setCurrentWorkout(
+														produce(currentWorkout, (draft) => {
+															draft.currentActionOrCompleted = true;
+														}),
+													);
 													const yes = await confirmWrapper({
 														confirmation: loaderData.isCreatingTemplate
 															? "Only sets that have data will added. Are you sure you want to save this template?"
@@ -472,11 +462,6 @@ export default function Page() {
 																	]),
 															)
 															.exhaustive();
-														notifications.show({
-															color: "green",
-															message: "Saved successfully",
-														});
-														Cookies.remove(workoutCookieName);
 														revalidator.revalidate();
 														if (loaderData.action === FitnessAction.LogWorkout)
 															events.createWorkout();
@@ -515,7 +500,6 @@ export default function Page() {
 												}
 												navigate($path("/"), { replace: true });
 												revalidator.revalidate();
-												Cookies.remove(workoutCookieName);
 												setCurrentWorkout(RESET);
 											}
 										}}
@@ -1634,6 +1618,27 @@ const SetDisplay = (props: {
 						>
 							{!set.note ? "Add" : "Remove"} note
 						</Menu.Item>
+						<Menu.Item
+							fz="xs"
+							leftSection={<IconZzz size={14} />}
+							onClick={() => {
+								setCurrentWorkout(
+									produce(currentWorkout, (draft) => {
+										const hasRestTimer = !!set.restTimer;
+										if (hasRestTimer)
+											draft.exercises[props.exerciseIdx].sets[
+												props.setIdx
+											].restTimer = undefined;
+										else
+											draft.exercises[props.exerciseIdx].sets[
+												props.setIdx
+											].restTimer = { duration: 60 };
+									}),
+								);
+							}}
+						>
+							{!set.restTimer ? "Add" : "Remove"} timer
+						</Menu.Item>
 						<Menu.Item
 							color="red"
 							fz="xs"
diff --git a/apps/frontend/app/routes/_dashboard.fitness.$entity.$id._index.tsx b/apps/frontend/app/routes/_dashboard.fitness.$entity.$id._index.tsx
index 70a5faf650..9d6810d0bb 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.$entity.$id._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.$entity.$id._index.tsx
@@ -247,6 +247,7 @@ export default function Page() {
 		setIsWorkoutLoading(true);
 		const workout = await duplicateOldWorkout(
 			loaderData.entityName,
+			params.action,
 			loaderData.information,
 			coreDetails,
 			userPreferences.fitness,
diff --git a/apps/frontend/app/routes/_dashboard.fitness.$entity.list.tsx b/apps/frontend/app/routes/_dashboard.fitness.$entity.list.tsx
index 68177e1b24..4d332ccff1 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.$entity.list.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.$entity.list.tsx
@@ -156,16 +156,14 @@ export default function Page() {
 								});
 								return;
 							}
-							startWorkout(
-								getDefaultWorkout(),
-								match(loaderData.entity)
-									.with(FitnessEntity.Workouts, () => FitnessAction.LogWorkout)
-									.with(
-										FitnessEntity.Templates,
-										() => FitnessAction.CreateTemplate,
-									)
-									.exhaustive(),
-							);
+							const action = match(loaderData.entity)
+								.with(FitnessEntity.Workouts, () => FitnessAction.LogWorkout)
+								.with(
+									FitnessEntity.Templates,
+									() => FitnessAction.CreateTemplate,
+								)
+								.exhaustive();
+							startWorkout(getDefaultWorkout(action), action);
 						}}
 					>
 						<IconPlus size={16} />
diff --git a/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx b/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx
index 7d73e0799b..414c0f5461 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.exercises.item.$id._index.tsx
@@ -82,6 +82,7 @@ import {
 } from "~/lib/generals";
 import {
 	useComplexJsonUpdate,
+	useIsFitnessActionActive,
 	useUserDetails,
 	useUserPreferences,
 	useUserUnitSystem,
@@ -95,7 +96,6 @@ import { useAddEntityToCollection, useReviewEntity } from "~/lib/state/media";
 import {
 	createToastHeaders,
 	getCachedExerciseParameters,
-	getWorkoutCookieValue,
 	serverGqlService,
 } from "~/lib/utilities.server";
 
@@ -110,7 +110,6 @@ const paramsSchema = { id: z.string() };
 export const loader = async ({ params, request }: LoaderFunctionArgs) => {
 	const { id: exerciseId } = zx.parseParams(params, paramsSchema);
 	const query = zx.parseQuery(request, searchParamsSchema);
-	const workoutInProgress = !!getWorkoutCookieValue(request);
 	const [exerciseParameters, { exerciseDetails }, { userExerciseDetails }] =
 		await Promise.all([
 			getCachedExerciseParameters(),
@@ -125,7 +124,6 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
 		query,
 		exerciseId,
 		exerciseDetails,
-		workoutInProgress,
 		exerciseParameters,
 		userExerciseDetails,
 	};
@@ -173,6 +171,7 @@ export default function Page() {
 		loaderData.userExerciseDetails.details?.exerciseNumTimesInteracted || 0;
 	const [currentWorkout, setCurrentWorkout] = useCurrentWorkout();
 	const navigate = useNavigate();
+	const isFitnessActionActive = useIsFitnessActionActive();
 	const [_a, setAddEntityToCollectionData] = useAddEntityToCollection();
 	const [timeSpanForCharts, setTimeSpanForCharts] = useLocalStorage(
 		"ExerciseChartTimeSpan",
@@ -636,7 +635,7 @@ export default function Page() {
 						) : null}
 					</Tabs>
 				</Stack>
-				{currentWorkout && loaderData.workoutInProgress ? (
+				{currentWorkout && isFitnessActionActive ? (
 					<Affix position={{ bottom: rem(40), right: rem(30) }}>
 						<ActionIcon
 							color="blue"
diff --git a/apps/frontend/app/routes/_dashboard.fitness.exercises.list.tsx b/apps/frontend/app/routes/_dashboard.fitness.exercises.list.tsx
index 8a66c3badf..ebce282d0c 100644
--- a/apps/frontend/app/routes/_dashboard.fitness.exercises.list.tsx
+++ b/apps/frontend/app/routes/_dashboard.fitness.exercises.list.tsx
@@ -48,6 +48,7 @@ import { DebouncedSearchInput, FiltersModal } from "~/components/common";
 import { dayjsLib, pageQueryParam } from "~/lib/generals";
 import {
 	useAppSearchParam,
+	useIsFitnessActionActive,
 	useUserCollections,
 	useUserPreferences,
 } from "~/lib/hooks";
@@ -55,7 +56,6 @@ import { addExerciseToWorkout, useCurrentWorkout } from "~/lib/state/fitness";
 import {
 	getCachedExerciseParameters,
 	getEnhancedCookieName,
-	getWorkoutCookieValue,
 	redirectToFirstPageIfOnInvalidPage,
 	redirectUsingEnhancedCookieSearchParams,
 	serverGqlService,
@@ -91,7 +91,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
 	const cookieName = await getEnhancedCookieName("exercises.list", request);
 	await redirectUsingEnhancedCookieSearchParams(request, cookieName);
 	const query = zx.parseQuery(request, searchParamsSchema);
-	const workoutInProgress = !!getWorkoutCookieValue(request);
 	query.sortBy = query.sortBy ?? defaultFiltersValue.sortBy;
 	query[pageQueryParam] = query[pageQueryParam] ?? 1;
 	const [exerciseParameters, { exercisesList }] = await Promise.all([
@@ -126,7 +125,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
 		totalPages,
 		cookieName,
 		exercisesList,
-		workoutInProgress,
 		exerciseParameters,
 	};
 };
@@ -140,6 +138,7 @@ export default function Page() {
 	const navigate = useNavigate();
 	const userPreferences = useUserPreferences();
 	const [currentWorkout, setCurrentWorkout] = useCurrentWorkout();
+	const isFitnessActionActive = useIsFitnessActionActive();
 	const [_, { setP }] = useAppSearchParam(loaderData.cookieName);
 	const [selectedExercises, setSelectedExercises] = useListState<{
 		name: string;
@@ -159,7 +158,7 @@ export default function Page() {
 
 	const allowAddingExerciseToWorkout =
 		currentWorkout &&
-		loaderData.workoutInProgress &&
+		isFitnessActionActive &&
 		!isNumber(currentWorkout.replacingExerciseIdx);
 
 	return (
diff --git a/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx b/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
index 152f6d13b6..3d926e60d9 100644
--- a/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
@@ -1679,7 +1679,6 @@ const DisplaySeasonOrEpisodeDetails = (props: {
 	publishDate?: string | null;
 }) => {
 	const [parent] = useAutoAnimate();
-	const [displayOverview, setDisplayOverview] = useDisclosure(false);
 	const swt = (t: string) => (
 		<Text size="xs" c="dimmed">
 			{t}
@@ -1696,11 +1695,6 @@ const DisplaySeasonOrEpisodeDetails = (props: {
 			: null,
 		props.publishDate ? swt(dayjsLib(props.publishDate).format("ll")) : null,
 		props.numEpisodes ? swt(`${props.numEpisodes} episodes`) : null,
-		props.overview ? (
-			<Anchor key="overview" size="xs" onClick={setDisplayOverview.toggle}>
-				{displayOverview ? "Hide" : "Show"} overview
-			</Anchor>
-		) : null,
 	].filter((s) => s !== null);
 	const display =
 		filteredElements.length > 0
@@ -1727,23 +1721,23 @@ const DisplaySeasonOrEpisodeDetails = (props: {
 			<Flex align="center" gap="sm" justify={{ md: "space-between" }}>
 				<Group wrap="nowrap">
 					<Indicator
+						size={16}
+						offset={7}
+						color="cyan"
 						disabled={!isSeen}
+						position="bottom-end"
+						style={{ zIndex: 0 }}
 						label={
 							props.displayIndicator === 1
 								? "Seen"
 								: `Seen × ${props.displayIndicator}`
 						}
-						offset={7}
-						position="bottom-end"
-						size={16}
-						color="cyan"
-						style={{ zIndex: 0 }}
 					>
 						<Avatar
-							src={props.posterImages[0]}
-							name={props.name}
-							radius="xl"
 							size="lg"
+							radius="xl"
+							name={props.name}
+							src={props.posterImages[0]}
 							imageProps={{ loading: "lazy" }}
 						/>
 					</Indicator>
@@ -1758,13 +1752,13 @@ const DisplaySeasonOrEpisodeDetails = (props: {
 			<Box hiddenFrom="md">
 				<DisplayDetails />
 			</Box>
-			{props.overview && displayOverview ? (
+			{props.overview ? (
 				<Text
 					size="sm"
 					c="dimmed"
+					lineClamp={5}
 					// biome-ignore lint/security/noDangerouslySetInnerHtml: generated on the backend securely
 					dangerouslySetInnerHTML={{ __html: props.overview }}
-					lineClamp={5}
 				/>
 			) : null}
 		</Stack>
diff --git a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx
index 6f11da7b74..a6b909467c 100644
--- a/apps/frontend/app/routes/_dashboard.settings.preferences.tsx
+++ b/apps/frontend/app/routes/_dashboard.settings.preferences.tsx
@@ -43,6 +43,7 @@ import {
 import {
 	camelCase,
 	changeCase,
+	cn,
 	isNumber,
 	snakeCase,
 	startCase,
@@ -54,7 +55,6 @@ import {
 	IconGripVertical,
 	IconRotate360,
 } from "@tabler/icons-react";
-import clsx from "clsx";
 import { Fragment, useState } from "react";
 import { match } from "ts-pattern";
 import { z } from "zod";
@@ -65,11 +65,11 @@ import {
 	useComplexJsonUpdate,
 	useConfirmSubmit,
 	useDashboardLayoutData,
+	useIsFitnessActionActive,
 	useUserPreferences,
 } from "~/lib/hooks";
 import {
 	createToastHeaders,
-	isWorkoutActive,
 	redirectIfNotAuthenticatedOrUpdated,
 	serverGqlService,
 } from "~/lib/utilities.server";
@@ -81,8 +81,7 @@ const searchSchema = z.object({
 
 export const loader = async ({ request }: LoaderFunctionArgs) => {
 	const query = zx.parseQuery(request, searchSchema);
-	const workoutInProgress = isWorkoutActive(request);
-	return { query, workoutInProgress };
+	return { query };
 };
 
 export const meta = (_args: MetaArgs<typeof loader>) => {
@@ -131,6 +130,7 @@ export default function Page() {
 	const loaderData = useLoaderData<typeof loader>();
 	const userPreferences = useUserPreferences();
 	const submit = useConfirmSubmit();
+	const isFitnessActionActive = useIsFitnessActionActive();
 	const [watchProviders, setWatchProviders] = useState(
 		userPreferences.general.watchProviders.map((wp) => ({
 			...wp,
@@ -153,7 +153,7 @@ export default function Page() {
 				<Affix
 					position={{
 						bottom: rem(45),
-						right: rem(loaderData.workoutInProgress ? 100 : 40),
+						right: rem(isFitnessActionActive ? 100 : 40),
 					}}
 				>
 					<Form
@@ -666,7 +666,7 @@ const EditDashboardElement = (props: {
 					p="xs"
 					ref={provided.innerRef}
 					{...provided.draggableProps}
-					className={clsx({ [classes.itemDragging]: snapshot.isDragging })}
+					className={cn({ [classes.itemDragging]: snapshot.isDragging })}
 				>
 					<Group justify="space-between">
 						<Group>
diff --git a/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx b/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx
index 35d6e970c9..b75f267c60 100644
--- a/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx
+++ b/apps/frontend/app/routes/_dashboard.settings.profile-and-sharing.tsx
@@ -288,15 +288,20 @@ export default function Page() {
 														visiting {applicationBaseUrl}/u/{userDetails.name}
 													</Text>
 												</Box>
-												<Button
-													w="30%"
-													type="submit"
-													variant="light"
-													disabled={isEditDisabled}
-													color={hasDefaultAccountLink ? "blue" : "green"}
+												<Tooltip
+													disabled={!isEditDisabled}
+													label="Can not change default access links for demo user"
 												>
-													{hasDefaultAccountLink ? "Disable" : "Enable"}
-												</Button>
+													<Button
+														w="30%"
+														type="submit"
+														variant="light"
+														disabled={isEditDisabled}
+														color={hasDefaultAccountLink ? "blue" : "green"}
+													>
+														{hasDefaultAccountLink ? "Disable" : "Enable"}
+													</Button>
+												</Tooltip>
 											</Group>
 										</Form>
 									</Paper>
diff --git a/apps/frontend/app/routes/_dashboard.tsx b/apps/frontend/app/routes/_dashboard.tsx
index 04fc27c855..24bc79f7ea 100644
--- a/apps/frontend/app/routes/_dashboard.tsx
+++ b/apps/frontend/app/routes/_dashboard.tsx
@@ -99,7 +99,6 @@ import {
 	IconSun,
 } from "@tabler/icons-react";
 import { produce } from "immer";
-import { jwtDecode } from "jwt-decode";
 import { type FormEvent, useState } from "react";
 import { Fragment } from "react/jsx-runtime";
 import { $path } from "remix-routes";
@@ -120,6 +119,7 @@ import {
 	useConfirmSubmit,
 	useCoreDetails,
 	useGetWatchProviders,
+	useIsFitnessActionActive,
 	useMetadataDetails,
 	useUserCollections,
 	useUserDetails,
@@ -134,11 +134,10 @@ import {
 	useReviewEntity,
 } from "~/lib/state/media";
 import {
-	getAuthorizationCookie,
 	getCachedCoreDetails,
 	getCachedUserCollectionsList,
 	getCachedUserPreferences,
-	isWorkoutActive,
+	getDecodedJwt,
 	redirectIfNotAuthenticatedOrUpdated,
 } from "~/lib/utilities.server";
 import { colorSchemeCookie } from "~/lib/utilities.server";
@@ -240,9 +239,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
 		request.headers.get("cookie") || "",
 	);
 
-	const decodedCookie = jwtDecode<{
-		access_link?: { id: string; is_demo?: boolean };
-	}>(getAuthorizationCookie(request) ?? "");
+	const decodedCookie = getDecodedJwt(request);
 	const isAccessLinkSession = Boolean(decodedCookie?.access_link);
 	const isDemo = Boolean(decodedCookie?.access_link?.is_demo);
 
@@ -252,8 +249,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
 		!coreDetails.disableTelemetry &&
 		!isDemo;
 
-	const workoutInProgress = isWorkoutActive(request);
-
 	return {
 		isDemo,
 		mediaLinks,
@@ -264,7 +259,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
 		userPreferences,
 		shouldHaveUmami,
 		userCollections,
-		workoutInProgress,
 		currentColorScheme,
 		isAccessLinkSession,
 	};
@@ -352,6 +346,7 @@ export default function Layout() {
 	const userDetails = useUserDetails();
 	const [parent] = useAutoAnimate();
 	const submit = useConfirmSubmit();
+	const isFitnessActionActive = useIsFitnessActionActive();
 	const [openedLinkGroups, setOpenedLinkGroups] = useLocalStorage<
 		| {
 				media: boolean;
@@ -405,7 +400,7 @@ export default function Layout() {
 
 	return (
 		<>
-			{loaderData.workoutInProgress &&
+			{isFitnessActionActive &&
 			!Object.values(FitnessAction)
 				.map((action) => $path("/fitness/:action", { action }))
 				.includes(location.pathname) ? (
diff --git a/apps/frontend/package.json b/apps/frontend/package.json
index a3d0a45abf..4d67783249 100644
--- a/apps/frontend/package.json
+++ b/apps/frontend/package.json
@@ -56,6 +56,7 @@
 		"recharts": "2.13.1",
 		"remix-routes": "1.7.7",
 		"remix-utils": "7.7.0",
+		"tailwind-merge": "2.5.4",
 		"tiny-invariant": "1.3.3",
 		"ts-pattern": "5.5.0",
 		"ufo": "1.5.4",
diff --git a/apps/website/app/drizzle/migrations/0003_same_gorilla_man.sql b/apps/website/app/drizzle/migrations/0003_same_gorilla_man.sql
new file mode 100644
index 0000000000..c6ed0eedee
--- /dev/null
+++ b/apps/website/app/drizzle/migrations/0003_same_gorilla_man.sql
@@ -0,0 +1,3 @@
+ALTER TYPE "public"."plan_type" ADD VALUE 'free' BEFORE 'monthly';--> statement-breakpoint
+ALTER TABLE "customer" DROP CONSTRAINT "customer_paddle_first_transaction_id_unique";--> statement-breakpoint
+ALTER TABLE "customer" DROP COLUMN IF EXISTS "paddle_first_transaction_id";
\ No newline at end of file
diff --git a/apps/website/app/drizzle/migrations/meta/0003_snapshot.json b/apps/website/app/drizzle/migrations/meta/0003_snapshot.json
new file mode 100644
index 0000000000..5165cc0830
--- /dev/null
+++ b/apps/website/app/drizzle/migrations/meta/0003_snapshot.json
@@ -0,0 +1,174 @@
+{
+	"id": "606551ca-2277-4f7c-a916-7be8a145bc9f",
+	"prevId": "706194d8-7177-4603-9a70-49955b7b2c2f",
+	"version": "7",
+	"dialect": "postgresql",
+	"tables": {
+		"public.contact_submission": {
+			"name": "contact_submission",
+			"schema": "",
+			"columns": {
+				"id": {
+					"name": "id",
+					"type": "uuid",
+					"primaryKey": true,
+					"notNull": true,
+					"default": "gen_random_uuid()"
+				},
+				"created_at": {
+					"name": "created_at",
+					"type": "timestamp",
+					"primaryKey": false,
+					"notNull": true,
+					"default": "now()"
+				},
+				"email": {
+					"name": "email",
+					"type": "text",
+					"primaryKey": false,
+					"notNull": true
+				},
+				"message": {
+					"name": "message",
+					"type": "text",
+					"primaryKey": false,
+					"notNull": true
+				},
+				"is_spam": {
+					"name": "is_spam",
+					"type": "boolean",
+					"primaryKey": false,
+					"notNull": false
+				},
+				"is_addressed": {
+					"name": "is_addressed",
+					"type": "boolean",
+					"primaryKey": false,
+					"notNull": false
+				}
+			},
+			"indexes": {},
+			"foreignKeys": {},
+			"compositePrimaryKeys": {},
+			"uniqueConstraints": {},
+			"checkConstraints": {}
+		},
+		"public.customer": {
+			"name": "customer",
+			"schema": "",
+			"columns": {
+				"id": {
+					"name": "id",
+					"type": "uuid",
+					"primaryKey": true,
+					"notNull": true,
+					"default": "gen_random_uuid()"
+				},
+				"email": {
+					"name": "email",
+					"type": "text",
+					"primaryKey": false,
+					"notNull": true
+				},
+				"created_on": {
+					"name": "created_on",
+					"type": "timestamp with time zone",
+					"primaryKey": false,
+					"notNull": true,
+					"default": "now()"
+				},
+				"oidc_issuer_id": {
+					"name": "oidc_issuer_id",
+					"type": "text",
+					"primaryKey": false,
+					"notNull": false
+				},
+				"paddle_customer_id": {
+					"name": "paddle_customer_id",
+					"type": "text",
+					"primaryKey": false,
+					"notNull": false
+				},
+				"product_type": {
+					"name": "product_type",
+					"type": "product_type",
+					"typeSchema": "public",
+					"primaryKey": false,
+					"notNull": false
+				},
+				"plan_type": {
+					"name": "plan_type",
+					"type": "plan_type",
+					"typeSchema": "public",
+					"primaryKey": false,
+					"notNull": false
+				},
+				"renew_on": {
+					"name": "renew_on",
+					"type": "date",
+					"primaryKey": false,
+					"notNull": false
+				},
+				"unkey_key_id": {
+					"name": "unkey_key_id",
+					"type": "text",
+					"primaryKey": false,
+					"notNull": false
+				},
+				"ryot_user_id": {
+					"name": "ryot_user_id",
+					"type": "text",
+					"primaryKey": false,
+					"notNull": false
+				},
+				"has_cancelled": {
+					"name": "has_cancelled",
+					"type": "boolean",
+					"primaryKey": false,
+					"notNull": false
+				}
+			},
+			"indexes": {},
+			"foreignKeys": {},
+			"compositePrimaryKeys": {},
+			"uniqueConstraints": {
+				"customer_email_unique": {
+					"name": "customer_email_unique",
+					"nullsNotDistinct": false,
+					"columns": ["email"]
+				},
+				"customer_oidc_issuer_id_unique": {
+					"name": "customer_oidc_issuer_id_unique",
+					"nullsNotDistinct": false,
+					"columns": ["oidc_issuer_id"]
+				},
+				"customer_paddle_customer_id_unique": {
+					"name": "customer_paddle_customer_id_unique",
+					"nullsNotDistinct": false,
+					"columns": ["paddle_customer_id"]
+				}
+			},
+			"checkConstraints": {}
+		}
+	},
+	"enums": {
+		"public.plan_type": {
+			"name": "plan_type",
+			"schema": "public",
+			"values": ["free", "monthly", "yearly", "lifetime"]
+		},
+		"public.product_type": {
+			"name": "product_type",
+			"schema": "public",
+			"values": ["cloud", "self_hosted"]
+		}
+	},
+	"schemas": {},
+	"sequences": {},
+	"views": {},
+	"_meta": {
+		"columns": {},
+		"schemas": {},
+		"tables": {}
+	}
+}
diff --git a/apps/website/app/drizzle/migrations/meta/_journal.json b/apps/website/app/drizzle/migrations/meta/_journal.json
index 47f22e1cc9..d27dc08d55 100644
--- a/apps/website/app/drizzle/migrations/meta/_journal.json
+++ b/apps/website/app/drizzle/migrations/meta/_journal.json
@@ -22,6 +22,13 @@
 			"when": 1723715178311,
 			"tag": "0002_sad_pixie",
 			"breakpoints": true
+		},
+		{
+			"idx": 3,
+			"version": "7",
+			"when": 1730122312096,
+			"tag": "0003_same_gorilla_man",
+			"breakpoints": true
 		}
 	]
 }
diff --git a/apps/website/app/drizzle/schema.server.ts b/apps/website/app/drizzle/schema.server.ts
index 900e28d3a0..cdd9e48071 100644
--- a/apps/website/app/drizzle/schema.server.ts
+++ b/apps/website/app/drizzle/schema.server.ts
@@ -13,10 +13,19 @@ export const productTypes = pgEnum("product_type", ["cloud", "self_hosted"]);
 
 export const ProductTypes = z.enum(productTypes.enumValues);
 
-export const planTypes = pgEnum("plan_type", ["monthly", "yearly", "lifetime"]);
+export type TProductTypes = z.infer<typeof ProductTypes>;
+
+export const planTypes = pgEnum("plan_type", [
+	"free",
+	"monthly",
+	"yearly",
+	"lifetime",
+]);
 
 export const PlanTypes = z.enum(planTypes.enumValues);
 
+export type TPlanTypes = z.infer<typeof PlanTypes>;
+
 export const customers = pgTable("customer", {
 	id: uuid("id").notNull().primaryKey().defaultRandom(),
 	email: text("email").notNull().unique(),
@@ -25,7 +34,6 @@ export const customers = pgTable("customer", {
 		.notNull(),
 	oidcIssuerId: text("oidc_issuer_id").unique(),
 	paddleCustomerId: text("paddle_customer_id").unique(),
-	paddleFirstTransactionId: text("paddle_first_transaction_id").unique(),
 	productType: productTypes("product_type"),
 	planType: planTypes("plan_type"),
 	renewOn: date("renew_on"),
diff --git a/apps/website/app/entry.server.tsx b/apps/website/app/entry.server.tsx
index 1106cf9cd2..fe526ee035 100644
--- a/apps/website/app/entry.server.tsx
+++ b/apps/website/app/entry.server.tsx
@@ -2,27 +2,15 @@ import { PassThrough } from "node:stream";
 import type { AppLoadContext, EntryContext } from "@remix-run/node";
 import { createReadableStreamFromReadable } from "@remix-run/node";
 import { RemixServer } from "@remix-run/react";
-import { drizzle } from "drizzle-orm/postgres-js";
 import { migrate } from "drizzle-orm/postgres-js/migrator";
 import { isbot } from "isbot";
-import postgres from "postgres";
 import { renderToPipeableStream } from "react-dom/server";
-import { serverVariables } from "./lib/config.server";
+import { db } from "./lib/config.server";
 
-const migrationClient = postgres(serverVariables.DATABASE_URL, { max: 1 });
-
-migrate(drizzle(migrationClient), {
-	migrationsFolder: "app/drizzle/migrations",
-})
-	.then(() => {
-		migrationClient.end();
-		console.log("Database migrations complete");
-	})
-	.catch((error) => {
-		console.error("Database migrations failed", error);
-		migrationClient.end();
-		process.exit(1);
-	});
+migrate(db, { migrationsFolder: "app/drizzle/migrations" }).catch((error) => {
+	console.error("Database migrations failed", error);
+	process.exit(1);
+});
 
 const ABORT_DELAY = 5_000;
 
diff --git a/apps/website/app/lib/components/Pricing.tsx b/apps/website/app/lib/components/Pricing.tsx
index 6118103e8e..05f160132a 100644
--- a/apps/website/app/lib/components/Pricing.tsx
+++ b/apps/website/app/lib/components/Pricing.tsx
@@ -14,18 +14,18 @@ import {
 
 export default function Pricing(props: {
 	prices: TPrices;
-	isLoggedIn: boolean;
+	isLoggedIn?: boolean;
 	onClick?: (priceId: string) => void;
 }) {
 	const [selectedProductTypeIndex, setSelectedProductTypeIndex] = useState(0);
 	const selectedProductType = props.prices[selectedProductTypeIndex];
 
 	return (
-		<section id="pricing" className="w-full py-12 md:py-24 lg:py-32">
+		<section id="pricing" className="w-full py-12 md:py-24 lg:py-32 bg-muted">
 			<div className="container space-y-12 px-4 md:px-6">
 				<div className="flex flex-col items-center justify-center space-y-4 text-center">
 					<div className="space-y-2">
-						<div className="inline-block rounded-lg bg-muted px-3 py-1 text-sm">
+						<div className="inline-block rounded-lg bg-white px-3 py-1 text-sm">
 							Pricing
 						</div>
 						<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl">
@@ -56,10 +56,7 @@ export default function Pricing(props: {
 						</span>
 						.{" "}
 						{selectedProductType.type === "self_hosted" ? (
-							<Link
-								to={$path("/comparison-to-community")}
-								className="text-blue-400 underline"
-							>
+							<Link to={$path("/features")} className="text-blue-400 underline">
 								See differences.
 							</Link>
 						) : null}
@@ -67,7 +64,7 @@ export default function Pricing(props: {
 					<div className="mx-auto flex justify-center items-center gap-8 text-center flex-wrap">
 						{selectedProductType.prices.map((p) => (
 							<div
-								className="grid gap-y-3 border rounded-xl py-3 w-72"
+								className="grid gap-y-3 border rounded-xl py-3 w-72 bg-white"
 								key={p.name}
 							>
 								<p className="text-3xl">{changeCase(p.name)}</p>
diff --git a/apps/website/app/lib/components/ui/button.tsx b/apps/website/app/lib/components/ui/button.tsx
index ba912f2723..a63eee99de 100644
--- a/apps/website/app/lib/components/ui/button.tsx
+++ b/apps/website/app/lib/components/ui/button.tsx
@@ -1,7 +1,7 @@
 import { Slot } from "@radix-ui/react-slot";
+import { cn } from "@ryot/ts-utils";
 import { type VariantProps, cva } from "class-variance-authority";
 import * as React from "react";
-import { cn } from "~/lib/utils";
 
 const buttonVariants = cva(
 	"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
diff --git a/apps/website/app/lib/components/ui/card.tsx b/apps/website/app/lib/components/ui/card.tsx
index ea25b83094..ca2c3d7072 100644
--- a/apps/website/app/lib/components/ui/card.tsx
+++ b/apps/website/app/lib/components/ui/card.tsx
@@ -1,7 +1,6 @@
+import { cn } from "@ryot/ts-utils";
 import * as React from "react";
 
-import { cn } from "app/lib/utils";
-
 const Card = React.forwardRef<
 	HTMLDivElement,
 	React.HTMLAttributes<HTMLDivElement>
diff --git a/apps/website/app/lib/components/ui/carousel.tsx b/apps/website/app/lib/components/ui/carousel.tsx
index 60c760410e..4cf557d300 100644
--- a/apps/website/app/lib/components/ui/carousel.tsx
+++ b/apps/website/app/lib/components/ui/carousel.tsx
@@ -1,12 +1,11 @@
+import { cn } from "@ryot/ts-utils";
+import { Button } from "app/lib/components/ui/button";
 import useEmblaCarousel, {
 	type UseEmblaCarouselType,
 } from "embla-carousel-react";
 import { ArrowLeft, ArrowRight } from "lucide-react";
 import * as React from "react";
 
-import { Button } from "app/lib/components/ui/button";
-import { cn } from "app/lib/utils";
-
 type CarouselApi = UseEmblaCarouselType[1];
 type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
 type CarouselOptions = UseCarouselParameters[0];
diff --git a/apps/website/app/lib/components/ui/input-otp.tsx b/apps/website/app/lib/components/ui/input-otp.tsx
index 2778f55fa5..eea7d70344 100644
--- a/apps/website/app/lib/components/ui/input-otp.tsx
+++ b/apps/website/app/lib/components/ui/input-otp.tsx
@@ -1,9 +1,8 @@
+import { cn } from "@ryot/ts-utils";
 import { OTPInput, OTPInputContext } from "input-otp";
 import { Dot } from "lucide-react";
 import * as React from "react";
 
-import { cn } from "app/lib/utils";
-
 const InputOTP = React.forwardRef<
 	React.ElementRef<typeof OTPInput>,
 	React.ComponentPropsWithoutRef<typeof OTPInput>
diff --git a/apps/website/app/lib/components/ui/input.tsx b/apps/website/app/lib/components/ui/input.tsx
index 8a5c0fcaf3..4cbd8bfb58 100644
--- a/apps/website/app/lib/components/ui/input.tsx
+++ b/apps/website/app/lib/components/ui/input.tsx
@@ -1,7 +1,6 @@
+import { cn } from "@ryot/ts-utils";
 import * as React from "react";
 
-import { cn } from "app/lib/utils";
-
 export interface InputProps
 	extends React.InputHTMLAttributes<HTMLInputElement> {}
 
diff --git a/apps/website/app/lib/components/ui/label.tsx b/apps/website/app/lib/components/ui/label.tsx
index b1826a3e78..9a9c6014a9 100644
--- a/apps/website/app/lib/components/ui/label.tsx
+++ b/apps/website/app/lib/components/ui/label.tsx
@@ -1,9 +1,8 @@
 import * as LabelPrimitive from "@radix-ui/react-label";
+import { cn } from "@ryot/ts-utils";
 import { type VariantProps, cva } from "class-variance-authority";
 import * as React from "react";
 
-import { cn } from "app/lib/utils";
-
 const labelVariants = cva(
 	"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
 );
diff --git a/apps/website/app/lib/components/ui/textarea.tsx b/apps/website/app/lib/components/ui/textarea.tsx
index 3d5d49598d..226b1b054e 100644
--- a/apps/website/app/lib/components/ui/textarea.tsx
+++ b/apps/website/app/lib/components/ui/textarea.tsx
@@ -1,7 +1,6 @@
+import { cn } from "@ryot/ts-utils";
 import * as React from "react";
 
-import { cn } from "app/lib/utils";
-
 export interface TextareaProps
 	extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
 
diff --git a/apps/website/app/lib/components/ui/tooltip.tsx b/apps/website/app/lib/components/ui/tooltip.tsx
index 9743001a0f..7c740468ce 100644
--- a/apps/website/app/lib/components/ui/tooltip.tsx
+++ b/apps/website/app/lib/components/ui/tooltip.tsx
@@ -1,8 +1,7 @@
 import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import { cn } from "@ryot/ts-utils";
 import * as React from "react";
 
-import { cn } from "app/lib/utils";
-
 const TooltipProvider = TooltipPrimitive.Provider;
 
 const Tooltip = TooltipPrimitive.Root;
diff --git a/apps/website/app/lib/config.server.ts b/apps/website/app/lib/config.server.ts
index 63e670ef84..9ee23b7a18 100644
--- a/apps/website/app/lib/config.server.ts
+++ b/apps/website/app/lib/config.server.ts
@@ -1,11 +1,14 @@
 import { Environment, Paddle } from "@paddle/paddle-node-sdk";
 import { render } from "@react-email/render";
 import { createCookie } from "@remix-run/node";
+import { formatDateToNaiveDate } from "@ryot/ts-utils";
+import { Unkey } from "@unkey/api";
+import type { Dayjs } from "dayjs";
+import { eq } from "drizzle-orm";
 import { drizzle } from "drizzle-orm/postgres-js";
 import { GraphQLClient } from "graphql-request";
 import { createTransport } from "nodemailer";
 import { Issuer } from "openid-client";
-import postgres from "postgres";
 import { Honeypot } from "remix-utils/honeypot/server";
 import { z } from "zod";
 import { zx } from "zodix";
@@ -33,6 +36,7 @@ export const serverVariablesSchema = z.object({
 	SERVER_OIDC_ISSUER_URL: z.string(),
 	SERVER_OIDC_CLIENT_SECRET: z.string(),
 	SERVER_ADMIN_ACCESS_TOKEN: z.string(),
+	PADDLE_WEBHOOK_SECRET_KEY: z.string(),
 	PADDLE_SANDBOX: zx.BoolAsString.optional(),
 });
 
@@ -42,14 +46,14 @@ export const OAUTH_CALLBACK_URL = `${serverVariables.FRONTEND_URL}/callback`;
 
 export const pricesSchema = z.array(
 	z.object({
-		type: z.string(),
+		type: z.nativeEnum(ProductTypes.Values),
 		prices: z.array(
 			z.object({
-				name: z.string(),
-				linkToGithub: z.boolean().optional(),
-				priceId: z.string().optional(),
-				amount: z.number().optional(),
 				trial: z.number().optional(),
+				amount: z.number().optional(),
+				priceId: z.string().optional(),
+				linkToGithub: z.boolean().optional(),
+				name: z.nativeEnum(PlanTypes.Values),
 			}),
 		),
 	}),
@@ -62,20 +66,14 @@ export const prices = pricesSchema.parse(
 );
 
 export const getProductAndPlanTypeByPriceId = (priceId: string) => {
-	for (const product of prices) {
-		for (const price of product.prices) {
-			if (price.priceId === priceId) {
-				return {
-					productType: ProductTypes.parse(product.type),
-					planType: PlanTypes.parse(price.name),
-				};
-			}
-		}
-	}
+	for (const product of prices)
+		for (const price of product.prices)
+			if (price.priceId === priceId)
+				return { productType: product.type, planType: price.name };
 	throw new Error("Price ID not found");
 };
 
-export const db = drizzle(postgres(serverVariables.DATABASE_URL), {
+export const db = drizzle(serverVariables.DATABASE_URL, {
 	schema,
 	logger: serverVariables.NODE_ENV === "development",
 });
@@ -112,6 +110,7 @@ export const sendEmail = async (
 	});
 	const html = await render(element, { pretty: true });
 	const text = await render(element, { plainText: true });
+	console.log(`Sending email to ${recipient} with subject ${subject}`);
 	const resp = await client.sendMail({
 		text,
 		html,
@@ -122,15 +121,18 @@ export const sendEmail = async (
 	return resp.messageId;
 };
 
-export const authCookie = createCookie("WebsiteAuth", {
+export const websiteAuthCookie = createCookie("WebsiteAuth", {
 	maxAge: 60 * 60 * 24 * 365,
 	path: "/",
 });
 
-export const getUserIdFromCookie = async (request: Request) => {
-	const cookie = await authCookie.parse(request.headers.get("cookie"));
-	if (!cookie) return null;
-	return z.string().parse(cookie);
+export const getCustomerFromCookie = async (request: Request) => {
+	const cookie = await websiteAuthCookie.parse(request.headers.get("cookie"));
+	if (!cookie || Object.keys(cookie).length === 0) return null;
+	const customerId = z.string().parse(cookie);
+	return await db.query.customers.findFirst({
+		where: eq(schema.customers.id, customerId),
+	});
 };
 
 export const serverGqlService = new GraphQLClient(
@@ -139,3 +141,24 @@ export const serverGqlService = new GraphQLClient(
 );
 
 export const honeypot = new Honeypot();
+
+export const customDataSchema = z.object({
+	customerId: z.string(),
+});
+
+export type CustomData = z.infer<typeof customDataSchema>;
+
+export const createUnkeyKey = async (
+	customer: typeof schema.customers.$inferSelect,
+	renewOn?: Dayjs,
+) => {
+	const unkey = new Unkey({ rootKey: serverVariables.UNKEY_ROOT_KEY });
+	const created = await unkey.keys.create({
+		name: customer.email,
+		externalId: customer.id,
+		apiId: serverVariables.UNKEY_API_ID,
+		meta: renewOn ? { expiry: formatDateToNaiveDate(renewOn) } : undefined,
+	});
+	if (created.error) throw new Error(created.error.message);
+	return created.result;
+};
diff --git a/apps/website/app/lib/utils.ts b/apps/website/app/lib/utils.ts
index ac680b303c..1e7581e942 100644
--- a/apps/website/app/lib/utils.ts
+++ b/apps/website/app/lib/utils.ts
@@ -1,6 +1,7 @@
-import { type ClassValue, clsx } from "clsx";
-import { twMerge } from "tailwind-merge";
+import { $path } from "remix-routes";
+import { withFragment } from "ufo";
 
-export function cn(...inputs: ClassValue[]) {
-	return twMerge(clsx(inputs));
-}
+export const startUrl = withFragment($path("/"), "start-here");
+
+export const logoUrl =
+	"https://raw.githubusercontent.com/IgnisDa/ryot/main/libs/assets/icon-512x512.png";
diff --git a/apps/website/app/root.tsx b/apps/website/app/root.tsx
index 97444c2bb0..7ce77b0b33 100644
--- a/apps/website/app/root.tsx
+++ b/apps/website/app/root.tsx
@@ -17,7 +17,8 @@ import type {
 import { $path } from "remix-routes";
 import { withFragment } from "ufo";
 import { Toaster } from "./lib/components/ui/sonner";
-import { getUserIdFromCookie, honeypot } from "./lib/config.server";
+import { getCustomerFromCookie, honeypot } from "./lib/config.server";
+import { logoUrl, startUrl } from "./lib/utils";
 
 export const links: LinksFunction = () => {
 	return [
@@ -41,9 +42,9 @@ export const links: LinksFunction = () => {
 };
 
 export const loader = async ({ request }: LoaderFunctionArgs) => {
-	const userId = await getUserIdFromCookie(request);
+	const customer = await getCustomerFromCookie(request);
 	return {
-		isLoggedIn: !!userId,
+		isLoggedIn: !!customer,
 		honeypotInputProps: honeypot.getInputProps(),
 	};
 };
@@ -74,23 +75,15 @@ export default function App() {
 				<div className="flex flex-col min-h-dvh">
 					<header className="px-4 lg:px-6 h-14 flex items-center">
 						<Link to={$path("/")} className="flex items-center justify-center">
-							<img
-								src="https://raw.githubusercontent.com/IgnisDa/ryot/main/libs/assets/icon-512x512.png"
-								alt="Ryot"
-								className="size-10 mr-2"
-							/>
+							<img alt="Ryot" src={logoUrl} className="size-10 mr-2" />
 							<span className="text-xl hidden md:block">Ryot</span>
 						</Link>
 						<nav className="ml-auto flex gap-4 sm:gap-6">
 							<Link
-								to={
-									loaderData.isLoggedIn
-										? $path("/me")
-										: withFragment($path("/"), "start-here")
-								}
-								className="text-sm font-medium hover:underline underline-offset-4"
+								to={$path("/features")}
+								className="text-sm font-medium hover:underline underline-offset-4 hidden md:block"
 							>
-								Your account
+								Features
 							</Link>
 							<Link
 								to={withFragment($path("/"), "pricing")}
@@ -98,6 +91,12 @@ export default function App() {
 							>
 								Pricing
 							</Link>
+							<Link
+								to={loaderData.isLoggedIn ? $path("/me") : startUrl}
+								className="text-sm font-medium hover:underline underline-offset-4"
+							>
+								Your account
+							</Link>
 							<a
 								target="_blank"
 								href="https://docs.ryot.io"
diff --git a/apps/website/app/routes/_index.tsx b/apps/website/app/routes/_index.tsx
index 044d528b90..12b46c7adc 100644
--- a/apps/website/app/routes/_index.tsx
+++ b/apps/website/app/routes/_index.tsx
@@ -1,12 +1,18 @@
+import { randomBytes } from "node:crypto";
 import TTLCache from "@isaacs/ttlcache";
 import {
 	type ActionFunctionArgs,
 	type LoaderFunctionArgs,
 	redirect,
 } from "@remix-run/node";
-import { Form, Link, useLoaderData } from "@remix-run/react";
+import {
+	Form,
+	Link,
+	useLoaderData,
+	useRouteLoaderData,
+} from "@remix-run/react";
 import LoginCodeEmail from "@ryot/transactional/emails/LoginCode";
-import { getActionIntent, processSubmission } from "@ryot/ts-utils";
+import { cn, getActionIntent, processSubmission } from "@ryot/ts-utils";
 import {
 	IconBrandDiscord,
 	IconBrandGithub,
@@ -15,9 +21,7 @@ import {
 } from "@tabler/icons-react";
 import dayjs from "dayjs";
 import duration from "dayjs/plugin/duration";
-import Autoplay from "embla-carousel-autoplay";
-import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
-import { nanoid } from "nanoid";
+import { REGEXP_ONLY_DIGITS } from "input-otp";
 import { $path } from "remix-routes";
 import { HoneypotInputs } from "remix-utils/honeypot/react";
 import { SpamError } from "remix-utils/honeypot/server";
@@ -28,13 +32,6 @@ import { zx } from "zodix";
 import { contactSubmissions, customers } from "~/drizzle/schema.server";
 import Pricing from "~/lib/components/Pricing";
 import { Button } from "~/lib/components/ui/button";
-import {
-	Carousel,
-	CarouselContent,
-	CarouselItem,
-	CarouselNext,
-	CarouselPrevious,
-} from "~/lib/components/ui/carousel";
 import { Input } from "~/lib/components/ui/input";
 import {
 	InputOTP,
@@ -43,15 +40,15 @@ import {
 } from "~/lib/components/ui/input-otp";
 import { Textarea } from "~/lib/components/ui/textarea";
 import {
-	authCookie,
 	db,
-	getUserIdFromCookie,
 	honeypot,
 	oauthClient,
 	prices,
 	sendEmail,
+	websiteAuthCookie,
 } from "~/lib/config.server";
-import { cn } from "~/lib/utils";
+import { startUrl } from "~/lib/utils";
+import type { loader as rootLoader } from "../root";
 
 dayjs.extend(duration);
 
@@ -64,8 +61,7 @@ export type SearchParams = z.infer<typeof searchParamsSchema>;
 
 export const loader = async ({ request }: LoaderFunctionArgs) => {
 	const query = zx.parseQuery(request, searchParamsSchema);
-	const userId = await getUserIdFromCookie(request);
-	return { prices, query, isLoggedIn: !!userId };
+	return { prices, query };
 };
 
 const otpCodesCache = new TTLCache<string, string>({
@@ -73,13 +69,20 @@ const otpCodesCache = new TTLCache<string, string>({
 	max: 1000,
 });
 
+const generateOtp = (length: number) => {
+	const max = 10 ** length;
+	const buffer = randomBytes(Math.ceil(length / 2));
+	const otp = Number.parseInt(buffer.toString("hex"), 16) % max;
+	return otp.toString().padStart(length, "0");
+};
+
 export const action = async ({ request }: ActionFunctionArgs) => {
 	const formData = await request.clone().formData();
 	const intent = getActionIntent(request);
 	return await match(intent)
 		.with("sendLoginCode", async () => {
 			const { email } = processSubmission(formData, emailSchema);
-			const otpCode = nanoid(6);
+			const otpCode = generateOtp(6);
 			otpCodesCache.set(email, otpCode);
 			console.log(`OTP code for ${email} is ${otpCode}`);
 			await sendEmail(
@@ -87,7 +90,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
 				LoginCodeEmail.subject,
 				LoginCodeEmail({ code: otpCode }),
 			);
-			return redirect(withQuery(withFragment(".", "start-here"), { email }));
+			return redirect(withQuery(startUrl, { email }));
 		})
 		.with("registerWithEmail", async () => {
 			const submission = processSubmission(formData, registerSchema);
@@ -105,7 +108,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
 			if (!customerId)
 				throw new Error("There was an error registering the user.");
 			return redirect($path("/me"), {
-				headers: { "set-cookie": await authCookie.serialize(customerId) },
+				headers: {
+					"set-cookie": await websiteAuthCookie.serialize(customerId),
+				},
 			});
 		})
 		.with("registerWithOidc", async () => {
@@ -136,9 +141,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
 		.run();
 };
 
-const intentSchema = z.object({ intent: z.string() });
-
-const emailSchema = z.object({ email: z.string().email() }).merge(intentSchema);
+const emailSchema = z.object({ email: z.string().email() });
 
 const registerSchema = z
 	.object({ otpCode: z.string().length(6) })
@@ -151,6 +154,7 @@ const contactSubmissionSchema = z.object({
 
 export default function Page() {
 	const loaderData = useLoaderData<typeof loader>();
+	const rootLoaderData = useRouteLoaderData<typeof rootLoader>("root");
 
 	return (
 		<>
@@ -227,52 +231,46 @@ export default function Page() {
 				</div>
 			</section>
 			<section
-				id="showcase"
+				id="testimonials"
 				className="w-full py-12 md:py-24 lg:py-32 bg-muted"
 			>
-				<div className="container space-y-12 px-4 md:px-6">
-					<div className="flex flex-col items-center justify-center space-y-4 text-center">
-						<div className="space-y-2">
-							<div className="inline-block rounded-lg bg-muted px-3 py-1 text-sm bg-white">
-								Ease of Use
-							</div>
-							<h1 className="lg:leading-tighter text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl xl:text-[3.4rem] 2xl:text-[3.75rem]">
-								Focus on What Matters
-							</h1>
-						</div>
+				<div className="container grid items-center justify-center gap-4 px-4 text-center md:px-6 lg:gap-10">
+					<div className="space-y-3">
+						<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
+							Trusted by Thousands
+						</h2>
+						<p className="mx-auto max-w-[700px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
+							Ryot is trusted by thousands of users worldwide to track their
+							media, fitness, and more. Join them and upgrade your tracking
+							experience today.
+						</p>
 					</div>
-					<Carousel plugins={[Autoplay({ delay: 5000 })]}>
-						<CarouselContent>
-							{showCarouselContents.map((carousel) => (
-								<CarouselItem key={carousel.text} className="flex flex-col">
-									<p className="mx-auto max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
-										{carousel.text}
-									</p>
-									<div className="my-auto">
-										<Image
-											src={carousel.image}
-											alt={carousel.alt}
-											className={cn(
-												carousel.smallImage && "hidden md:block",
-												"max-h-96 md:max-w-3xl lg:max-w-4xl xl:max-w-6xl",
-											)}
-										/>
-										{carousel.smallImage ? (
-											<Image
-												src={carousel.smallImage}
-												alt={carousel.alt}
-												className="md:hidden"
-											/>
-										) : null}
-									</div>
-								</CarouselItem>
-							))}
-						</CarouselContent>
-						<div className="hidden md:block">
-							<CarouselPrevious />
-							<CarouselNext />
+					<div className="divide-y rounded-lg border">
+						<div className="mx-auto md:flex w-full items-center justify-center p-4 sm:p-8 space-y-4 gap-x-4">
+							<Image
+								src="https://cdn.fosstodon.org/accounts/avatars/110/575/489/884/720/641/original/7312eb6f27068401.jpeg"
+								alt="Fosstodon logo"
+								className="size-20 rounded-full flex-none"
+							/>
+							<p>
+								I love how easy it is to quickly add a game, book, movie or show
+								after I'm finished and write a short review. It's probably the
+								most used software on my home server, and has greatly encouraged
+								me to watch more movies! IgnisDa has put a great amount of
+								attention to detail into this software, and is always available
+								for help any step of the way! -{" "}
+								<a
+									href="https://fosstodon.org/@beppi"
+									target="_blank"
+									rel="noopener noreferrer"
+									className="hover:underline"
+								>
+									<strong>@beppi</strong>
+								</a>
+							</p>
 						</div>
-					</Carousel>
+						<div className="grid w-full grid-cols-3 items-stretch justify-center divide" />
+					</div>
 				</div>
 			</section>
 			<section id="start-here" className="w-full py-12 md:py-24 lg:py-32">
@@ -287,7 +285,7 @@ export default function Page() {
 						</p>
 					</div>
 					<div className="mx-auto w-full max-w-sm space-y-2">
-						{loaderData.isLoggedIn ? (
+						{rootLoaderData?.isLoggedIn ? (
 							<Link to={$path("/me")}>
 								<Button>
 									<IconPlayerPlay size={16} className="mr-2" />
@@ -299,7 +297,7 @@ export default function Page() {
 								<Form
 									method="POST"
 									className="flex gap-2 flex-none"
-									action={withQuery(".", {
+									action={withQuery(".?index", {
 										intent: loaderData.query.email
 											? "registerWithEmail"
 											: "sendLoginCode",
@@ -315,7 +313,7 @@ export default function Page() {
 											/>
 											<InputOTP
 												maxLength={6}
-												pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
+												pattern={REGEXP_ONLY_DIGITS}
 												name="otpCode"
 											>
 												<InputOTPGroup>
@@ -345,7 +343,7 @@ export default function Page() {
 								<p className="text-xs">OR</p>
 								<Form
 									method="POST"
-									action={withQuery(".", { intent: "registerWithOidc" })}
+									action={withQuery(".?index", { intent: "registerWithOidc" })}
 								>
 									<Button
 										variant="outline"
@@ -369,55 +367,15 @@ export default function Page() {
 					</div>
 				</div>
 			</section>
-			<section
-				id="testimonials"
-				className="w-full py-12 md:py-24 lg:py-32 bg-muted"
-			>
-				<div className="container grid items-center justify-center gap-4 px-4 text-center md:px-6 lg:gap-10">
-					<div className="space-y-3">
-						<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
-							Trusted by Thousands
-						</h2>
-						<p className="mx-auto max-w-[700px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
-							Ryot is trusted by thousands of users worldwide to track their
-							media, fitness, and more. Join them and upgrade your tracking
-							experience today.
-						</p>
-					</div>
-					<div className="divide-y rounded-lg border">
-						<div className="mx-auto md:flex w-full items-center justify-center p-4 sm:p-8 space-y-4 gap-x-4">
-							<Image
-								src="https://cdn.fosstodon.org/accounts/avatars/110/575/489/884/720/641/original/7312eb6f27068401.jpeg"
-								alt="Fosstodon logo"
-								className="size-20 rounded-full flex-none"
-							/>
-							<p>
-								I love how easy it is to quickly add a game, book, movie or show
-								after I'm finished and write a short review. It's probably the
-								most used software on my home server, and has greatly encouraged
-								me to watch more movies! IgnisDa has put a great amount of
-								attention to detail into this software, and is always available
-								for help any step of the way! -{" "}
-								<a
-									href="https://fosstodon.org/@beppi"
-									target="_blank"
-									rel="noopener noreferrer"
-									className="hover:underline"
-								>
-									<strong>@beppi</strong>
-								</a>
-							</p>
-						</div>
-						<div className="grid w-full grid-cols-3 items-stretch justify-center divide" />
-					</div>
-				</div>
-			</section>
-			<Pricing prices={loaderData.prices} isLoggedIn={loaderData.isLoggedIn} />
-			<section id="contact" className="w-full py-12 md:py-24 lg:py-32 bg-muted">
+			<Pricing
+				prices={loaderData.prices}
+				isLoggedIn={rootLoaderData?.isLoggedIn}
+			/>
+			<section id="contact" className="w-full py-12 md:py-24 lg:py-32">
 				<div className="container px-4 md:px-6">
 					<div className="flex flex-col items-center justify-center space-y-4 text-center">
 						<div className="space-y-2">
-							<div className="inline-block rounded-lg px-3 py-1 text-sm bg-white">
+							<div className="inline-block rounded-lg px-3 py-1 text-sm bg-muted">
 								Contact Us
 							</div>
 							<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
@@ -437,7 +395,7 @@ export default function Page() {
 					) : (
 						<Form
 							method="POST"
-							action={withQuery(".", { intent: "contactSubmission" })}
+							action={withQuery(".?index", { intent: "contactSubmission" })}
 							className="flex flex-col items-center justify-center pt-12 gap-y-4 gap-x-4"
 						>
 							<HoneypotInputs />
@@ -459,11 +417,14 @@ export default function Page() {
 					)}
 				</div>
 			</section>
-			<section id="community" className="w-full py-12 md:py-24 lg:py-32">
+			<section
+				id="community"
+				className="w-full py-12 md:py-24 lg:py-32 bg-muted"
+			>
 				<div className="container px-4 md:px-6">
 					<div className="flex flex-col items-center justify-center space-y-4 text-center">
 						<div className="space-y-2">
-							<div className="inline-block rounded-lg px-3 py-1 text-sm bg-muted">
+							<div className="inline-block rounded-lg px-3 py-1 text-sm bg-white">
 								Open Source
 							</div>
 							<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl">
@@ -555,22 +516,3 @@ const Image = ({ src, alt, className }: ImageProps) => (
 		)}
 	/>
 );
-
-const showCarouselContents = [
-	{
-		text: "You can selectively enable or disable the facets you want to track. Spend less time learning the platform and more time tracking what matters to you. Get started in minutes.",
-		image: "/group.png",
-		smallImage: "/desktop.png",
-		alt: "Grouped images",
-	},
-	{
-		text: "Share your profile data with friends and family without compromising your privacy. Ryot allows you to create access links with limited access so that others can view your favorite movies without logging in.",
-		image: "/sharing.png",
-		alt: "Sharing images",
-	},
-	{
-		text: "Browse your favorite genres and get recommendations based on your preferences. Ryot uses advanced algorithms to suggest movies, shows, and books you might like.",
-		image: "/genres.png",
-		alt: "Genres images",
-	},
-];
diff --git a/apps/website/app/routes/callback.tsx b/apps/website/app/routes/callback.tsx
index 6b00293353..cde50aada6 100644
--- a/apps/website/app/routes/callback.tsx
+++ b/apps/website/app/routes/callback.tsx
@@ -5,9 +5,9 @@ import { match } from "ts-pattern";
 import { customers } from "~/drizzle/schema.server";
 import {
 	OAUTH_CALLBACK_URL,
-	authCookie,
 	db,
 	oauthClient,
+	websiteAuthCookie,
 } from "~/lib/config.server";
 
 export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -37,6 +37,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
 		.otherwise((value) => value.id);
 	if (!customerId) throw new Error("There was an error registering the user.");
 	return redirect($path("/me"), {
-		headers: { "set-cookie": await authCookie.serialize(customerId) },
+		headers: { "set-cookie": await websiteAuthCookie.serialize(customerId) },
 	});
 };
diff --git a/apps/website/app/routes/comparison-to-community.tsx b/apps/website/app/routes/comparison-to-community.tsx
deleted file mode 100644
index 86197617fc..0000000000
--- a/apps/website/app/routes/comparison-to-community.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import Autoplay from "embla-carousel-autoplay";
-import {
-	Carousel,
-	CarouselContent,
-	CarouselItem,
-	CarouselNext,
-	CarouselPrevious,
-} from "~/lib/components/ui/carousel";
-import { cn } from "~/lib/utils";
-
-export default function Page() {
-	return (
-		<section
-			id="comparison"
-			className="w-full py-12 md:py-24 lg:py-32 bg-muted"
-		>
-			<div className="container space-y-4 px-4 md:px-6 text-center">
-				<div className="space-y-2">
-					<div className="inline-block rounded-lg bg-muted px-3 py-1 text-sm bg-white">
-						Comparison
-					</div>
-				</div>
-				<Carousel plugins={[Autoplay({ delay: 5000 })]}>
-					<CarouselContent>
-						{features.map((feature) => (
-							<CarouselItem
-								key={feature.name}
-								className="flex flex-col space-y-4"
-							>
-								<h1 className="lg:leading-tighter text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl xl:text-[3.4rem] 2xl:text-[3.75rem]">
-									{feature.name}
-								</h1>
-								<p className="mx-auto max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
-									{feature.points.join(" ")}
-								</p>
-								<div className="my-auto flex items-center justify-center">
-									<img
-										src={feature.image}
-										alt={feature.name}
-										className={cn(
-											"rounded-xl max-h-96 md:max-w-3xl lg:max-w-4xl xl:max-w-6xl",
-											!feature.noBorder && "border rounded-lg shadow-lg",
-										)}
-									/>
-								</div>
-							</CarouselItem>
-						))}
-					</CarouselContent>
-					<div className="hidden md:block">
-						<CarouselPrevious />
-						<CarouselNext />
-					</div>
-				</Carousel>
-			</div>
-		</section>
-	);
-}
-
-const features = [
-	{
-		name: "Sharing",
-		image: "/sharing-form.png",
-		points: [
-			"Share your profile with your friends and family.",
-			"Set an expiry (or don't!) and revoke access at any time.",
-			"Anonymous users can only view your data, not change it.",
-		],
-	},
-	{
-		name: "Recommendations",
-		image: "/recommendations.png",
-		points: [
-			'A new section called "Recommendations" is added to the Dashboard where you can view personalized suggestions based on your recent media consumption. They are refreshed every hour.',
-		],
-	},
-	{
-		name: "Supercharged Collections",
-		image: "/supercharged-collections.png",
-		noBorder: true,
-		points: [
-			'Create information templates for your collections. These templates will be used when you add media to your collection. You can see an example of this on the community edition with the "Owned" and "Reminders" collection.',
-			"Add collaborators to your collections. This feature is useful when you want to share your collection with your friends or family. They can also add or remove media from it.",
-		],
-	},
-	{
-		name: "Other Enhancements",
-		image: "/other-enhancements.png",
-		points: [
-			"Create templates to schedule future workouts.",
-			'Option to automatically sync media items from integrations to the "Owned" collection.',
-			'Easier navigation for the "History" tab in media details to your favorite episode.',
-			"Easily view history for an exercise and copy it to your current workout.",
-			"Add notes to individual sets in a workout.",
-			"Set time spent manually on seen entries, allowing for more accurate tracking of media consumption.",
-		],
-	},
-];
diff --git a/apps/website/app/routes/features.tsx b/apps/website/app/routes/features.tsx
new file mode 100644
index 0000000000..8675440711
--- /dev/null
+++ b/apps/website/app/routes/features.tsx
@@ -0,0 +1,287 @@
+import type { LoaderFunctionArgs, MetaArgs } from "@remix-run/node";
+import { cn } from "@ryot/ts-utils";
+import Autoplay from "embla-carousel-autoplay";
+import {
+	LucideAmpersands,
+	LucideBadgeInfo,
+	LucideBellDot,
+	LucideBookHeart,
+	LucideCalendarRange,
+	LucideCandy,
+	LucideChartColumnBig,
+	LucideChartLine,
+	LucideCog,
+	LucideDatabaseZap,
+	LucideDumbbell,
+	LucideImageUp,
+	LucideImport,
+	LucideLayoutTemplate,
+	LucideLibraryBig,
+	LucideMegaphone,
+	LucideMessageSquareText,
+	LucideNotebookPen,
+	LucideNotebookTabs,
+	LucidePackageOpen,
+	LucideProjector,
+	LucideRefreshCcwDot,
+	LucideRuler,
+	LucideScale3D,
+	LucideShare,
+	LucideSquareStack,
+	LucideTimer,
+	LucideToggleLeft,
+	LucideVibrate,
+	LucideWatch,
+} from "lucide-react";
+import {
+	Carousel,
+	CarouselContent,
+	CarouselItem,
+	CarouselNext,
+	CarouselPrevious,
+} from "~/lib/components/ui/carousel";
+import { logoUrl } from "~/lib/utils";
+
+export const loader = (_args: LoaderFunctionArgs) => {
+	return {};
+};
+
+export const meta = (_args: MetaArgs<typeof loader>) => {
+	return [{ title: "Features | Ryot" }];
+};
+
+export default function Page() {
+	return (
+		<div>
+			<div className="bg-muted py-20 md:py-32">
+				<img
+					alt="Ryot"
+					src={logoUrl}
+					className="size-28 sm:size-40 mx-auto mb-10"
+				/>
+				<div className="space-y-4">
+					<h1 className="text-center text-2xl sm:text-3xl">
+						Think of Ryot as your second brain with superpowers ✨
+					</h1>
+					<h2 className="text-center sm:text-xl text-gray-500">
+						What all can Ryot do for you?
+					</h2>
+				</div>
+			</div>
+			{dataToDisplay.map((data, index) => (
+				<div
+					key={data.heading}
+					className={cn(
+						"py-10 space-y-4 md:space-y-8",
+						index % 2 === 1 ? "bg-muted" : "bg-white",
+					)}
+				>
+					<h2 className="text-center text-2xl sm:text-3xl font-semibold lowercase underline underline-offset-4">
+						{data.heading}
+					</h2>
+					{data.images.length > 0 ? (
+						<Carousel plugins={[Autoplay({ delay: 5000 })]}>
+							<CarouselContent>
+								{data.images.map((image, index) => (
+									<CarouselItem key={image} className="flex flex-col space-y-4">
+										<img
+											src={`/features/${image}`}
+											alt={`${data.heading}-${index + 1}`}
+											className="mx-auto rounded-xl md:max-h-96 md:max-w-3xl lg:max-w-4xl xl:max-w-6xl object-contain"
+										/>
+									</CarouselItem>
+								))}
+							</CarouselContent>
+							<div className="hidden md:block">
+								<CarouselPrevious />
+								<CarouselNext />
+							</div>
+						</Carousel>
+					) : null}
+					<div className="px-2 md:px-0 mx-auto grid items-start gap-5 md:gap-8 sm:max-w-4xl sm:grid-cols-2 lg:max-w-5xl lg:grid-cols-3">
+						{data.features.map((f) => (
+							<div
+								className="flex items-center justify-center gap-x-3 h-full"
+								key={f.text}
+							>
+								<div
+									className={cn(
+										"p-2 border rounded-md",
+										f.isPro
+											? "border-black bg-green-200/20"
+											: "border-transparent",
+									)}
+								>
+									<f.icon className="size-6 flex-none mx-auto" />
+									{f.isPro ? (
+										<div className="text-green-700 text-[10px] mt-2 text-center font-semibold">
+											PRO
+										</div>
+									) : null}
+								</div>
+								<p className="text-muted-foreground">{f.text}</p>
+							</div>
+						))}
+					</div>
+				</div>
+			))}
+		</div>
+	);
+}
+
+const dataToDisplay = [
+	{
+		heading: "Media Tracking",
+		images: ["desktop.png", "genres.png", "group.png"],
+		features: [
+			{
+				icon: LucideNotebookTabs,
+				text: "Track everything you want: movies, shows, books, podcasts, games, anime, manga, music, visual novels",
+			},
+			{
+				icon: LucideLibraryBig,
+				text: "Add media to your watchlist, favorite or any other custom collection",
+			},
+			{
+				icon: LucideBookHeart,
+				text: "Get recommendations based on your favorites and watch history",
+				isPro: true,
+			},
+			{
+				icon: LucideNotebookPen,
+				text: "Track media you've watched and mark them as seen as many times as you want",
+			},
+			{
+				icon: LucideImport,
+				text: "Import your data from 13 different sources (with more to come)",
+			},
+			{
+				icon: LucideRefreshCcwDot,
+				text: "Integrations with 10 different services (with more on the way)",
+			},
+			{
+				icon: LucideChartColumnBig,
+				text: "Consolidated activity and statistics graphs and views across all your media",
+			},
+			{
+				icon: LucideWatch,
+				text: "Set time spent manually on seen entries for more accurate tracking of media consumption",
+				isPro: true,
+			},
+			{
+				icon: LucideMegaphone,
+				text: "Get notifications when a new episode is released or your favorite actor is back on screen",
+			},
+			{
+				icon: LucideVibrate,
+				text: "Support for 9 different notification platforms (more being released soon)",
+			},
+			{
+				icon: LucideBellDot,
+				text: "Set reminders for when you want to watch something and get notified",
+			},
+			{
+				icon: LucideMessageSquareText,
+				text: "Review media privately or publicly and see what others think",
+			},
+			{
+				icon: LucideProjector,
+				text: "Get information on where you can watch a movie/show legally in your country",
+			},
+			{
+				icon: LucidePackageOpen,
+				text: "Browse media by genre or groups (eg: Star Wars collection)",
+			},
+			{
+				icon: LucideCalendarRange,
+				text: "Calendar view to get an overview on when a media is coming out",
+			},
+			{
+				icon: LucideCandy,
+				text: "Suggestions that cater to your tastes based on your watch history",
+			},
+		],
+	},
+	{
+		heading: "Fitness Tracking",
+		images: [
+			"current-workout.png",
+			"measurements-graph.png",
+			"logged-workout.png",
+			"exercise-dataset.png",
+		],
+		features: [
+			{
+				icon: LucideDumbbell,
+				text: "Hit the gym and track workouts in realtime",
+			},
+			{
+				icon: LucideDatabaseZap,
+				text: "Dataset of over 800 exercises with instructions (and the ability to add your own)",
+			},
+			{
+				icon: LucideTimer,
+				text: "Add rest timers to each set you complete",
+			},
+			{
+				icon: LucideImageUp,
+				text: "Create supersets and upload images for each exercise to track progression",
+			},
+			{
+				icon: LucideSquareStack,
+				text: "Inline history and images of exercises while logging an active workout",
+			},
+			{
+				icon: LucideLayoutTemplate,
+				text: "Create templates to pre plan workouts beforehand",
+				isPro: true,
+			},
+			{
+				icon: LucideChartLine,
+				text: "Graphs of progress for exercises to visualize your progress over time",
+			},
+			{
+				icon: LucideRuler,
+				text: "Keep track of your measurements like body weight, sugar level etc.",
+			},
+			{
+				icon: LucideScale3D,
+				text: "Visualizations of how your measurements fluctuate over time. Use them to identify trends and patterns.",
+			},
+		],
+	},
+	{
+		heading: "Other Goodies",
+		images: [
+			"sharing.png",
+			"recommendations.png",
+			"sharing-form.png",
+			"supercharged-collections.png",
+		],
+		features: [
+			{
+				icon: LucideShare,
+				text: "Share access links to your data with your friends and family",
+				isPro: true,
+			},
+			{
+				icon: LucideCog,
+				text: "Fine grained preferences to customize exactly what you want to track",
+			},
+			{
+				icon: LucideAmpersands,
+				text: "Add collaborators to your collections to allow them to add to them",
+				isPro: true,
+			},
+			{
+				icon: LucideToggleLeft,
+				text: "Dark and light mode, because Ryot is at your fingertips the whole time",
+			},
+			{
+				icon: LucideBadgeInfo,
+				text: "Add custom information to your collections to make them more personalized",
+				isPro: true,
+			},
+		],
+	},
+];
diff --git a/apps/website/app/routes/me.tsx b/apps/website/app/routes/me.tsx
index 0cd2df04ad..6acb7df8fa 100644
--- a/apps/website/app/routes/me.tsx
+++ b/apps/website/app/routes/me.tsx
@@ -3,242 +3,186 @@ import {
 	type Paddle,
 	initializePaddle,
 } from "@paddle/paddle-js";
-import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
-import { Form, redirect, useLoaderData, useSubmit } from "@remix-run/react";
-import { RegisterUserDocument } from "@ryot/generated/graphql/backend/graphql";
+import type {
+	ActionFunctionArgs,
+	LoaderFunctionArgs,
+	MetaArgs,
+} from "@remix-run/node";
+import { Form, redirect, useLoaderData } from "@remix-run/react";
 import PurchaseCompleteEmail from "@ryot/transactional/emails/PurchaseComplete";
-import { changeCase, getActionIntent } from "@ryot/ts-utils";
-import { Unkey } from "@unkey/api";
+import {
+	changeCase,
+	formatDateToNaiveDate,
+	getActionIntent,
+} from "@ryot/ts-utils";
 import dayjs from "dayjs";
 import { eq } from "drizzle-orm";
-import { nanoid } from "nanoid";
 import { useEffect, useState } from "react";
-import { $path } from "remix-routes";
-import { P, match } from "ts-pattern";
-import { withFragment, withQuery } from "ufo";
+import { toast } from "sonner";
+import { match } from "ts-pattern";
+import { withQuery } from "ufo";
 import { customers } from "~/drizzle/schema.server";
 import Pricing from "~/lib/components/Pricing";
 import { Button } from "~/lib/components/ui/button";
 import { Card } from "~/lib/components/ui/card";
 import { Label } from "~/lib/components/ui/label";
 import {
-	GRACE_PERIOD,
-	authCookie,
+	type CustomData,
+	createUnkeyKey,
 	db,
-	getPaddleServerClient,
-	getProductAndPlanTypeByPriceId,
-	getUserIdFromCookie,
+	getCustomerFromCookie,
 	prices,
 	sendEmail,
-	serverGqlService,
 	serverVariables,
+	websiteAuthCookie,
 } from "~/lib/config.server";
+import { startUrl } from "~/lib/utils";
 
 export const loader = async ({ request }: LoaderFunctionArgs) => {
-	const userId = await getUserIdFromCookie(request);
-	const isLoggedIn = !!userId;
-	if (!isLoggedIn) return redirect(withFragment($path("/"), "start-here"));
-	const planDetails = await match(userId)
-		.with(P.string, async (userId) => {
-			const customer = await db.query.customers.findFirst({
-				where: eq(customers.id, userId),
-				columns: {
-					email: true,
-					renewOn: true,
-					planType: true,
-					productType: true,
-					unkeyKeyId: true,
-					ryotUserId: true,
-				},
-			});
-			if (!customer) return undefined;
-			return customer;
-		})
-		.otherwise(() => undefined);
+	const customerDetails = await getCustomerFromCookie(request);
+	if (!customerDetails) return redirect(startUrl);
 	return {
 		prices,
-		planDetails,
+		customerDetails,
 		isSandbox: !!serverVariables.PADDLE_SANDBOX,
 		clientToken: serverVariables.PADDLE_CLIENT_TOKEN,
 	};
 };
 
+export const meta = (_args: MetaArgs<typeof loader>) => {
+	return [{ title: "My account | Ryot" }];
+};
+
 export const action = async ({ request }: ActionFunctionArgs) => {
 	const intent = getActionIntent(request);
 	return await match(intent)
-		.with("logout", async () => {
-			const cookies = await authCookie.serialize("", { expires: new Date(0) });
-			return Response.json({}, { headers: { "set-cookie": cookies } });
-		})
-		.with("processPurchase", async () => {
-			const userId = await getUserIdFromCookie(request);
-			if (!userId)
-				throw new Error("You must be logged in to buy a subscription");
-			const { transactionId }: { transactionId: string } = await request.json();
-			const paddleClient = getPaddleServerClient();
-			const transaction = await paddleClient.transactions.get(transactionId);
-			const priceId = transaction.details?.lineItems[0].priceId;
-			if (!priceId) throw new Error("Price ID not found");
-			const paddleCustomerId = transaction.customerId;
-			if (!paddleCustomerId) throw new Error("Paddle customer ID not found");
-			const customer = await db.query.customers.findFirst({
-				where: eq(customers.id, userId),
-			});
-			if (!customer) throw new Error("Customer not found");
-			const { email, oidcIssuerId } = customer;
-			const { planType, productType } = getProductAndPlanTypeByPriceId(priceId);
-			const renewOn = match(planType)
-				.with("lifetime", () => undefined)
-				.with("yearly", () => dayjs().add(1, "year"))
-				.with("monthly", () => dayjs().add(1, "month"))
-				.exhaustive();
-			const { ryotUserId, unkeyKeyId, data } = await match(productType)
-				.with("cloud", async () => {
-					const password = nanoid(10);
-					const { registerUser } = await serverGqlService.request(
-						RegisterUserDocument,
-						{
-							input: {
-								adminAccessToken: serverVariables.SERVER_ADMIN_ACCESS_TOKEN,
-								data: oidcIssuerId
-									? { oidc: { email: email, issuerId: oidcIssuerId } }
-									: { password: { username: email, password: password } },
-							},
-						},
-					);
-					if (registerUser.__typename === "RegisterError") {
-						console.error(registerUser);
-						throw new Error("Failed to register user");
-					}
-					return {
-						ryotUserId: registerUser.id,
-						unkeyKeyId: null,
-						data: {
-							__typename: "cloud" as const,
-							auth: oidcIssuerId ? email : { username: email, password },
-						},
-					};
-				})
-				.with("self_hosted", async () => {
-					const unkey = new Unkey({ rootKey: serverVariables.UNKEY_ROOT_KEY });
-					const created = await unkey.keys.create({
-						apiId: serverVariables.UNKEY_API_ID,
-						name: email,
-						meta: renewOn
-							? {
-									expiry: renewOn
-										.add(GRACE_PERIOD, "days")
-										.format("YYYY-MM-DD"),
-								}
-							: undefined,
-					});
-					if (created.error) throw new Error(created.error.message);
-					return {
-						ryotUserId: null,
-						unkeyKeyId: created.result.keyId,
-						data: {
-							__typename: "self_hosted" as const,
-							key: created.result.key,
-						},
-					};
-				})
-				.exhaustive();
-			const renewal = renewOn ? renewOn.format("YYYY-MM-DD") : undefined;
+		.with("regenerateUnkeyKey", async () => {
+			const customer = await getCustomerFromCookie(request);
+			if (!customer || !customer.planType) throw new Error("No customer found");
+			const renewOn = customer.renewOn ? dayjs(customer.renewOn) : undefined;
+			const created = await createUnkeyKey(customer, renewOn);
+			await db
+				.update(customers)
+				.set({ unkeyKeyId: created.keyId })
+				.where(eq(customers.id, customer.id));
+			const renewal = renewOn ? formatDateToNaiveDate(renewOn) : undefined;
 			await sendEmail(
 				customer.email,
 				PurchaseCompleteEmail.subject,
-				PurchaseCompleteEmail({ planType, renewOn: renewal, data }),
-			);
-			await db
-				.update(customers)
-				.set({
-					planType,
-					ryotUserId,
-					unkeyKeyId,
-					productType,
+				PurchaseCompleteEmail({
 					renewOn: renewal,
-					paddleCustomerId,
-					paddleFirstTransactionId: transactionId,
-				})
-				.where(eq(customers.id, userId));
+					planType: customer.planType,
+					details: { __typename: "self_hosted", key: created.key },
+				}),
+			);
 			return Response.json({});
 		})
+		.with("logout", async () => {
+			const cookies = await websiteAuthCookie.serialize("", {
+				expires: new Date(0),
+			});
+			return Response.json({}, { headers: { "set-cookie": cookies } });
+		})
 		.run();
 };
 
 export default function Index() {
 	const loaderData = useLoaderData<typeof loader>();
-	const submit = useSubmit();
 	const [paddle, setPaddle] = useState<Paddle>();
 
+	const paddleCustomerId = loaderData.customerDetails.paddleCustomerId;
+
 	useEffect(() => {
-		initializePaddle({
-			environment: loaderData.isSandbox ? "sandbox" : undefined,
-			token: loaderData.clientToken,
-			eventCallback: (data) => {
-				if (data.name === CheckoutEventNames.CHECKOUT_COMPLETED) {
-					const transactionId = data.data?.transaction_id;
-					if (!transactionId) throw new Error("Transaction ID not found");
-					submit(
-						{ transactionId },
-						{
-							method: "POST",
-							encType: "application/json",
-							action: withQuery(".", { intent: "processPurchase" }),
+		if (!paddle)
+			initializePaddle({
+				token: loaderData.clientToken,
+				environment: loaderData.isSandbox ? "sandbox" : undefined,
+			}).then((paddleInstance) => {
+				if (paddleInstance) {
+					paddleInstance.Update({
+						eventCallback: (data) => {
+							if (data.name === CheckoutEventNames.CHECKOUT_COMPLETED) {
+								paddleInstance.Checkout.close();
+								toast.loading(
+									"Purchase successful. Your order will be shipped shortly.",
+								);
+								setTimeout(() => window.location.reload(), 10000);
+							}
 						},
-					);
+					});
+					setPaddle(paddleInstance);
 				}
-			},
-		}).then((paddleInstance) => {
-			if (paddleInstance) setPaddle(paddleInstance);
-		});
+			});
 	}, []);
 
 	return (
 		<>
-			{loaderData.planDetails?.planType &&
-			loaderData.planDetails.productType ? (
+			{!loaderData.customerDetails.hasCancelled &&
+			loaderData.customerDetails.planType &&
+			loaderData.customerDetails.productType ? (
 				<Card className="w-full max-w-md p-6 grid gap-6 m-auto mt-40">
 					<div className="grid grid-cols-2 gap-4">
-						<div>
+						<div className="col-span-2">
 							<Label>Email</Label>
 							<p className="text-muted-foreground">
-								{loaderData.planDetails.email}
+								{loaderData.customerDetails.email}
 							</p>
 						</div>
-						{loaderData.planDetails.renewOn ? (
-							<div>
-								<Label>Renewal Status</Label>
-								<p className="text-muted-foreground">
-									Renews on {loaderData.planDetails.renewOn}
-								</p>
-							</div>
-						) : null}
 						<div>
-							<Label>Plan Type</Label>
+							<Label>Product Type</Label>
 							<p className="text-muted-foreground">
-								{changeCase(loaderData.planDetails.planType)}
+								{changeCase(loaderData.customerDetails.productType)}
 							</p>
 						</div>
 						<div>
-							<Label>Product Type</Label>
+							<Label>Plan Type</Label>
 							<p className="text-muted-foreground">
-								{changeCase(loaderData.planDetails.productType)}
+								{changeCase(loaderData.customerDetails.planType)}
 							</p>
 						</div>
-						{loaderData.planDetails.ryotUserId ? (
+						{loaderData.customerDetails.renewOn ? (
 							<div>
-								<Label>User ID</Label>
+								<Label>Renewal Status</Label>
 								<p className="text-muted-foreground">
-									{loaderData.planDetails.ryotUserId}
+									Renews on {loaderData.customerDetails.renewOn}
 								</p>
 							</div>
 						) : null}
-						{loaderData.planDetails.unkeyKeyId ? (
+						{loaderData.customerDetails.ryotUserId ? (
 							<div>
-								<Label>Key ID</Label>
+								<Label>User ID</Label>
 								<p className="text-muted-foreground">
-									{loaderData.planDetails.unkeyKeyId}
+									{loaderData.customerDetails.ryotUserId}
+								</p>
+							</div>
+						) : null}
+						{loaderData.customerDetails.unkeyKeyId ? (
+							<div className="col-span-2">
+								<div className="flex items-center justify-between">
+									<Label>Key ID</Label>
+									<Form
+										method="POST"
+										action={withQuery(".", { intent: "regenerateUnkeyKey" })}
+									>
+										<button
+											type="submit"
+											className="text-xs underline text-right"
+											onClick={(e) => {
+												const yes = confirm(
+													"Are you sure you want to regenerate the unkey key? All old unkey keys will be invalidated.",
+												);
+												if (!yes) e.preventDefault();
+											}}
+										>
+											Regenerate
+										</button>
+									</Form>
+								</div>
+								<p className="text-muted-foreground">
+									{loaderData.customerDetails.unkeyKeyId}
+								</p>
+								<p className="text-xs text-gray-500">
+									(This is the key ID; the pro key has been sent to your email)
 								</p>
 							</div>
 						) : null}
@@ -251,6 +195,13 @@ export default function Index() {
 					onClick={(priceId) => {
 						paddle?.Checkout.open({
 							items: [{ priceId, quantity: 1 }],
+							customer: paddleCustomerId
+								? { id: paddleCustomerId }
+								: { email: loaderData.customerDetails.email },
+							customData: {
+								customerId: loaderData.customerDetails.id,
+							} as CustomData,
+							settings: paddleCustomerId ? { allowLogout: false } : undefined,
 						});
 					}}
 				/>
diff --git a/apps/website/app/routes/paddle-webhook.tsx b/apps/website/app/routes/paddle-webhook.tsx
new file mode 100644
index 0000000000..bb58ae4fd7
--- /dev/null
+++ b/apps/website/app/routes/paddle-webhook.tsx
@@ -0,0 +1,215 @@
+import { EventName } from "@paddle/paddle-node-sdk";
+import type { ActionFunctionArgs } from "@remix-run/node";
+import {
+	RegisterUserDocument,
+	UpdateUserDocument,
+} from "@ryot/generated/graphql/backend/graphql";
+import PurchaseCompleteEmail from "@ryot/transactional/emails/PurchaseComplete";
+import { formatDateToNaiveDate } from "@ryot/ts-utils";
+import { Unkey } from "@unkey/api";
+import dayjs from "dayjs";
+import { eq } from "drizzle-orm";
+import { nanoid } from "nanoid";
+import { match } from "ts-pattern";
+import { type TPlanTypes, customers } from "~/drizzle/schema.server";
+import {
+	GRACE_PERIOD,
+	createUnkeyKey,
+	customDataSchema,
+	db,
+	getPaddleServerClient,
+	getProductAndPlanTypeByPriceId,
+	sendEmail,
+	serverGqlService,
+	serverVariables,
+} from "~/lib/config.server";
+
+const getRenewOnFromPlanType = (planType: TPlanTypes) =>
+	match(planType)
+		.with("free", "lifetime", () => undefined)
+		.with("yearly", () => dayjs().add(1, "year"))
+		.with("monthly", () => dayjs().add(1, "month"))
+		.exhaustive();
+
+export const action = async ({ request }: ActionFunctionArgs) => {
+	const paddleSignature = request.headers.get("paddle-signature");
+	if (!paddleSignature) return Response.json({ error: "No paddle signature" });
+
+	const paddleClient = getPaddleServerClient();
+	const requestBody = await request.text();
+	const eventData = paddleClient.webhooks.unmarshal(
+		requestBody,
+		serverVariables.PADDLE_WEBHOOK_SECRET_KEY,
+		paddleSignature,
+	);
+	if (!eventData)
+		return Response.json({ error: "No event data found in request body" });
+
+	const { eventType, data } = eventData;
+
+	console.log(`Received event: ${eventType}`);
+
+	if (eventType === EventName.TransactionCompleted) {
+		const paddleCustomerId = data.customerId;
+		if (!paddleCustomerId)
+			return Response.json({
+				error: "No customer ID found in transaction completed event",
+			});
+		console.log(
+			`Received transaction completed event for customer id: ${paddleCustomerId}`,
+		);
+		let customer = await db.query.customers.findFirst({
+			where: eq(customers.paddleCustomerId, paddleCustomerId),
+		});
+		if (!customer) {
+			const parsed = customDataSchema.safeParse(data.customData);
+			if (parsed.success)
+				customer = await db.query.customers.findFirst({
+					where: eq(customers.id, parsed.data.customerId),
+				});
+		}
+
+		if (!customer)
+			return Response.json({
+				error: `No customer found for customer ID: ${paddleCustomerId}`,
+			});
+
+		const unkey = new Unkey({ rootKey: serverVariables.UNKEY_ROOT_KEY });
+		if (!customer.planType) {
+			const priceId = data.details?.lineItems[0].priceId;
+			if (!priceId) return Response.json({ error: "Price ID not found" });
+
+			const { planType, productType } = getProductAndPlanTypeByPriceId(priceId);
+			console.log(
+				`Customer ${paddleCustomerId} purchased ${productType} with plan type ${planType}`,
+			);
+
+			const { email, oidcIssuerId } = customer;
+			const renewOn = getRenewOnFromPlanType(planType);
+			const { ryotUserId, unkeyKeyId, details } = await match(productType)
+				.with("cloud", async () => {
+					const password = nanoid(10);
+					const { registerUser } = await serverGqlService.request(
+						RegisterUserDocument,
+						{
+							input: {
+								adminAccessToken: serverVariables.SERVER_ADMIN_ACCESS_TOKEN,
+								data: oidcIssuerId
+									? { oidc: { email: email, issuerId: oidcIssuerId } }
+									: { password: { username: email, password: password } },
+							},
+						},
+					);
+					if (registerUser.__typename === "RegisterError") {
+						console.error(registerUser);
+						throw new Error("Failed to register user");
+					}
+					return {
+						ryotUserId: registerUser.id,
+						unkeyKeyId: null,
+						details: {
+							__typename: "cloud" as const,
+							auth: oidcIssuerId ? email : { username: email, password },
+						},
+					};
+				})
+				.with("self_hosted", async () => {
+					const created = await createUnkeyKey(
+						customer,
+						renewOn ? renewOn.add(GRACE_PERIOD, "days") : undefined,
+					);
+					return {
+						ryotUserId: null,
+						unkeyKeyId: created.keyId,
+						details: {
+							key: created.key,
+							__typename: "self_hosted" as const,
+						},
+					};
+				})
+				.exhaustive();
+			const renewal = renewOn ? formatDateToNaiveDate(renewOn) : undefined;
+			await sendEmail(
+				customer.email,
+				PurchaseCompleteEmail.subject,
+				PurchaseCompleteEmail({ planType, renewOn: renewal, details }),
+			);
+			await db
+				.update(customers)
+				.set({
+					planType,
+					ryotUserId,
+					unkeyKeyId,
+					productType,
+					renewOn: renewal,
+					paddleCustomerId,
+				})
+				.where(eq(customers.id, customer.id));
+		} else {
+			const renewal = getRenewOnFromPlanType(customer.planType);
+			const renewOn = renewal ? formatDateToNaiveDate(renewal) : undefined;
+			console.log(`Updating customer with renewOn: ${renewOn}`);
+			await db
+				.update(customers)
+				.set({ renewOn, hasCancelled: null })
+				.where(eq(customers.id, customer.id));
+			if (customer.ryotUserId)
+				await serverGqlService.request(UpdateUserDocument, {
+					input: {
+						isDisabled: false,
+						userId: customer.ryotUserId,
+						adminAccessToken: serverVariables.SERVER_ADMIN_ACCESS_TOKEN,
+					},
+				});
+			if (customer.unkeyKeyId)
+				await unkey.keys.update({
+					keyId: customer.unkeyKeyId,
+					meta: renewal
+						? {
+								expiry: formatDateToNaiveDate(
+									renewal.add(GRACE_PERIOD, "days"),
+								),
+							}
+						: undefined,
+				});
+		}
+	}
+
+	if (
+		eventType === EventName.SubscriptionCanceled ||
+		eventType === EventName.SubscriptionPaused ||
+		eventType === EventName.SubscriptionPastDue
+	) {
+		const customerId = data.customerId;
+		const customer = await db.query.customers.findFirst({
+			where: eq(customers.paddleCustomerId, customerId),
+		});
+		if (!customer) return Response.json({ message: "No customer found" });
+		await db
+			.update(customers)
+			.set({ hasCancelled: true })
+			.where(eq(customers.id, customer.id));
+		if (customer.ryotUserId)
+			await serverGqlService.request(UpdateUserDocument, {
+				input: {
+					isDisabled: true,
+					userId: customer.ryotUserId,
+					adminAccessToken: serverVariables.SERVER_ADMIN_ACCESS_TOKEN,
+				},
+			});
+	}
+
+	if (eventType === EventName.SubscriptionResumed) {
+		const customerId = data.customerId;
+		const customer = await db.query.customers.findFirst({
+			where: eq(customers.paddleCustomerId, customerId),
+		});
+		if (!customer) return Response.json({ message: "No customer found" });
+		await db
+			.update(customers)
+			.set({ hasCancelled: null })
+			.where(eq(customers.id, customer.id));
+	}
+
+	return Response.json({ message: "Webhook ran successfully" });
+};
diff --git a/apps/website/app/routes/terms.tsx b/apps/website/app/routes/terms.tsx
index 0a83bbc872..0bb5ccd3fe 100644
--- a/apps/website/app/routes/terms.tsx
+++ b/apps/website/app/routes/terms.tsx
@@ -1,5 +1,15 @@
+import type { LoaderFunctionArgs, MetaArgs } from "@remix-run/node";
+
 const email = "ignisda2001@gmail.com";
 
+export const loader = (_args: LoaderFunctionArgs) => {
+	return {};
+};
+
+export const meta = (_args: MetaArgs<typeof loader>) => {
+	return [{ title: "Terms and conditions | Ryot" }];
+};
+
 export default function Index() {
 	return (
 		<div className="my-48 prose lg:prose-lg container mx-auto">
diff --git a/apps/website/package.json b/apps/website/package.json
index f7bc4e9add..974fb4b909 100644
--- a/apps/website/package.json
+++ b/apps/website/package.json
@@ -50,6 +50,7 @@
 		"sonner": "1.5.0",
 		"tailwind-merge": "2.5.4",
 		"tailwindcss-animate": "1.0.7",
+		"tiny-invariant": "1.3.3",
 		"ts-pattern": "5.5.0",
 		"ufo": "1.5.4",
 		"zod": "3.23.8",
diff --git a/apps/website/public/features/current-workout.png b/apps/website/public/features/current-workout.png
new file mode 100644
index 0000000000..f7f8f83887
Binary files /dev/null and b/apps/website/public/features/current-workout.png differ
diff --git a/apps/website/public/desktop.png b/apps/website/public/features/desktop.png
similarity index 100%
rename from apps/website/public/desktop.png
rename to apps/website/public/features/desktop.png
diff --git a/apps/website/public/features/exercise-dataset.png b/apps/website/public/features/exercise-dataset.png
new file mode 100644
index 0000000000..8ef69e3156
Binary files /dev/null and b/apps/website/public/features/exercise-dataset.png differ
diff --git a/apps/website/public/genres.png b/apps/website/public/features/genres.png
similarity index 100%
rename from apps/website/public/genres.png
rename to apps/website/public/features/genres.png
diff --git a/apps/website/public/group.png b/apps/website/public/features/group.png
similarity index 100%
rename from apps/website/public/group.png
rename to apps/website/public/features/group.png
diff --git a/apps/website/public/features/logged-workout.png b/apps/website/public/features/logged-workout.png
new file mode 100644
index 0000000000..daa746f0db
Binary files /dev/null and b/apps/website/public/features/logged-workout.png differ
diff --git a/apps/website/public/features/measurements-graph.png b/apps/website/public/features/measurements-graph.png
new file mode 100644
index 0000000000..a168ce0ae7
Binary files /dev/null and b/apps/website/public/features/measurements-graph.png differ
diff --git a/apps/website/public/recommendations.png b/apps/website/public/features/recommendations.png
similarity index 100%
rename from apps/website/public/recommendations.png
rename to apps/website/public/features/recommendations.png
diff --git a/apps/website/public/sharing-form.png b/apps/website/public/features/sharing-form.png
similarity index 100%
rename from apps/website/public/sharing-form.png
rename to apps/website/public/features/sharing-form.png
diff --git a/apps/website/public/sharing.png b/apps/website/public/features/sharing.png
similarity index 100%
rename from apps/website/public/sharing.png
rename to apps/website/public/features/sharing.png
diff --git a/apps/website/public/supercharged-collections.png b/apps/website/public/features/supercharged-collections.png
similarity index 100%
rename from apps/website/public/supercharged-collections.png
rename to apps/website/public/features/supercharged-collections.png
diff --git a/apps/website/public/other-enhancements.png b/apps/website/public/other-enhancements.png
deleted file mode 100644
index 551c4ba484..0000000000
Binary files a/apps/website/public/other-enhancements.png and /dev/null differ
diff --git a/crates/services/collection/src/lib.rs b/crates/services/collection/src/lib.rs
index 3691114ce4..147f381d9f 100644
--- a/crates/services/collection/src/lib.rs
+++ b/crates/services/collection/src/lib.rs
@@ -262,11 +262,11 @@ impl CollectionService {
             .await?
             .unwrap();
         let reviews = item_reviews(
-            &self.0.db,
             &collection.user_id,
             &input.collection_id,
             EntityLot::Collection,
             true,
+            &self.0,
         )
         .await?;
         Ok(CollectionContents {
diff --git a/crates/services/exporter/src/lib.rs b/crates/services/exporter/src/lib.rs
index 52d1606c22..e6b665d885 100644
--- a/crates/services/exporter/src/lib.rs
+++ b/crates/services/exporter/src/lib.rs
@@ -215,7 +215,7 @@ impl ExporterService {
                     }
                 })
                 .collect();
-            let reviews = item_reviews(&self.0.db, user_id, &m.id, EntityLot::Metadata, false)
+            let reviews = item_reviews(user_id, &m.id, EntityLot::Metadata, false, &self.0)
                 .await?
                 .into_iter()
                 .map(|r| self.get_review_export_item(r))
@@ -258,7 +258,7 @@ impl ExporterService {
                 .await
                 .unwrap()
                 .unwrap();
-            let reviews = item_reviews(&self.0.db, user_id, &m.id, EntityLot::MetadataGroup, false)
+            let reviews = item_reviews(user_id, &m.id, EntityLot::MetadataGroup, false, &self.0)
                 .await?
                 .into_iter()
                 .map(|r| self.get_review_export_item(r))
@@ -300,7 +300,7 @@ impl ExporterService {
                 .await
                 .unwrap()
                 .unwrap();
-            let reviews = item_reviews(&self.0.db, user_id, &p.id, EntityLot::Person, false)
+            let reviews = item_reviews(user_id, &p.id, EntityLot::Person, false, &self.0)
                 .await?
                 .into_iter()
                 .map(|r| self.get_review_export_item(r))
@@ -385,7 +385,7 @@ impl ExporterService {
                 .await
                 .unwrap()
                 .unwrap();
-            let reviews = item_reviews(&self.0.db, user_id, &e.id, EntityLot::Exercise, false)
+            let reviews = item_reviews(user_id, &e.id, EntityLot::Exercise, false, &self.0)
                 .await?
                 .into_iter()
                 .map(|r| self.get_review_export_item(r))
diff --git a/crates/services/fitness/src/lib.rs b/crates/services/fitness/src/lib.rs
index 5c49ae4259..acb330348a 100644
--- a/crates/services/fitness/src/lib.rs
+++ b/crates/services/fitness/src/lib.rs
@@ -256,14 +256,8 @@ impl ExerciseService {
     ) -> Result<UserExerciseDetails> {
         let collections =
             entity_in_collections(&self.0.db, &user_id, &exercise_id, EntityLot::Exercise).await?;
-        let reviews = item_reviews(
-            &self.0.db,
-            &user_id,
-            &exercise_id,
-            EntityLot::Exercise,
-            true,
-        )
-        .await?;
+        let reviews =
+            item_reviews(&user_id, &exercise_id, EntityLot::Exercise, true, &self.0).await?;
         let mut resp = UserExerciseDetails {
             details: None,
             history: None,
diff --git a/crates/services/integration/src/lib.rs b/crates/services/integration/src/lib.rs
index 965539399f..14d0943b4c 100644
--- a/crates/services/integration/src/lib.rs
+++ b/crates/services/integration/src/lib.rs
@@ -8,7 +8,7 @@ use database_models::{
     prelude::{CollectionToEntity, Integration, Metadata, Seen, UserToEntity},
     seen, user_to_entity,
 };
-use database_utils::user_preferences_by_id;
+use database_utils::user_by_id;
 use dependent_models::ImportResult;
 use dependent_utils::{commit_metadata, process_import};
 use enums::{EntityLot, IntegrationLot, IntegrationProvider, MediaLot};
@@ -97,7 +97,7 @@ impl IntegrationService {
             .one(&self.0.db)
             .await?
             .ok_or_else(|| Error::new("Integration does not exist".to_owned()))?;
-        let preferences = user_preferences_by_id(&integration.user_id, &self.0).await?;
+        let preferences = user_by_id(&integration.user_id, &self.0).await?.preferences;
         if integration.is_disabled.unwrap_or_default() || preferences.general.disable_integrations {
             return Err(Error::new("Integration is disabled".to_owned()));
         }
@@ -259,7 +259,7 @@ impl IntegrationService {
     }
 
     pub async fn yank_integrations_data_for_user(&self, user_id: &String) -> GqlResult<bool> {
-        let preferences = user_preferences_by_id(user_id, &self.0).await?;
+        let preferences = user_by_id(user_id, &self.0).await?.preferences;
         if preferences.general.disable_integrations {
             return Ok(false);
         }
diff --git a/crates/services/miscellaneous/src/lib.rs b/crates/services/miscellaneous/src/lib.rs
index 1c736e2cef..b218f7ad34 100644
--- a/crates/services/miscellaneous/src/lib.rs
+++ b/crates/services/miscellaneous/src/lib.rs
@@ -37,7 +37,6 @@ use database_utils::{
     add_entity_to_collection, apply_collection_filter, calculate_user_activities_and_summary,
     entity_in_collections, entity_in_collections_with_collection_to_entity_ids, ilike_sql,
     item_reviews, remove_entity_from_collection, revoke_access_link, user_by_id,
-    user_preferences_by_id,
 };
 use dependent_models::{
     CoreDetails, GenreDetails, MetadataBaseData, MetadataGroupDetails, PersonDetails,
@@ -548,14 +547,8 @@ ORDER BY RANDOM() LIMIT 10;
         let media_details = self.generic_metadata(&metadata_id).await?;
         let collections =
             entity_in_collections(&self.0.db, &user_id, &metadata_id, EntityLot::Metadata).await?;
-        let reviews = item_reviews(
-            &self.0.db,
-            &user_id,
-            &metadata_id,
-            EntityLot::Metadata,
-            true,
-        )
-        .await?;
+        let reviews =
+            item_reviews(&user_id, &metadata_id, EntityLot::Metadata, true, &self.0).await?;
         let (_, history) = is_metadata_finished_by_user(&user_id, &metadata_id, &self.0.db).await?;
         let in_progress = history
             .iter()
@@ -740,8 +733,7 @@ ORDER BY RANDOM() LIMIT 10;
         user_id: String,
         person_id: String,
     ) -> Result<UserPersonDetails> {
-        let reviews =
-            item_reviews(&self.0.db, &user_id, &person_id, EntityLot::Person, true).await?;
+        let reviews = item_reviews(&user_id, &person_id, EntityLot::Person, true, &self.0).await?;
         let collections =
             entity_in_collections(&self.0.db, &user_id, &person_id, EntityLot::Person).await?;
         Ok(UserPersonDetails {
@@ -763,11 +755,11 @@ ORDER BY RANDOM() LIMIT 10;
         )
         .await?;
         let reviews = item_reviews(
-            &self.0.db,
             &user_id,
             &metadata_group_id,
             EntityLot::MetadataGroup,
             true,
+            &self.0,
         )
         .await?;
         Ok(UserMetadataGroupDetails {
@@ -971,7 +963,7 @@ ORDER BY RANDOM() LIMIT 10;
         user_id: String,
         input: MetadataListInput,
     ) -> Result<SearchResults<String>> {
-        let preferences = user_preferences_by_id(&user_id, &self.0).await?;
+        let preferences = user_by_id(&user_id, &self.0).await?.preferences;
 
         let avg_rating_col = "user_average_rating";
         let cloned_user_id_1 = user_id.clone();
@@ -1569,7 +1561,7 @@ ORDER BY RANDOM() LIMIT 10;
             });
         }
         let cloned_user_id = user_id.to_owned();
-        let preferences = user_preferences_by_id(user_id, &self.0).await?;
+        let preferences = user_by_id(user_id, &self.0).await?.preferences;
         let provider = get_metadata_provider(input.lot, input.source, &self.0).await?;
         let results = provider
             .metadata_search(&query, input.search.page, preferences.general.display_nsfw)
@@ -1644,7 +1636,7 @@ ORDER BY RANDOM() LIMIT 10;
                 items: vec![],
             });
         }
-        let preferences = user_preferences_by_id(user_id, &self.0).await?;
+        let preferences = user_by_id(user_id, &self.0).await?.preferences;
         let provider = self.get_non_metadata_provider(input.source).await?;
         let results = provider
             .people_search(
@@ -1672,7 +1664,7 @@ ORDER BY RANDOM() LIMIT 10;
                 items: vec![],
             });
         }
-        let preferences = user_preferences_by_id(user_id, &self.0).await?;
+        let preferences = user_by_id(user_id, &self.0).await?.preferences;
         let provider = get_metadata_provider(input.lot, input.source, &self.0).await?;
         let results = provider
             .metadata_group_search(&query, input.search.page, preferences.general.display_nsfw)
@@ -2485,7 +2477,7 @@ ORDER BY RANDOM() LIMIT 10;
                 .unwrap();
             comment.liked_by.remove(&user_id);
         } else {
-            let user = user_by_id(&self.0.db, &user_id).await?;
+            let user = user_by_id(&user_id, &self.0).await?;
             comments.push(ImportOrExportItemReviewComment {
                 id: nanoid!(20),
                 text: input.text.unwrap(),
diff --git a/crates/services/user/src/lib.rs b/crates/services/user/src/lib.rs
index 1d8b39ea54..7df4aa2b7d 100644
--- a/crates/services/user/src/lib.rs
+++ b/crates/services/user/src/lib.rs
@@ -14,7 +14,7 @@ use database_models::{
 use database_utils::{
     admin_account_guard, create_or_update_collection,
     deploy_job_to_calculate_user_activities_and_summary, ilike_sql, pro_instance_guard,
-    revoke_access_link, user_by_id, user_preferences_by_id,
+    revoke_access_link, user_by_id,
 };
 use dependent_models::UserDetailsResult;
 use enum_meta::Meta;
@@ -57,7 +57,7 @@ pub struct UserService(pub Arc<SupportingService>);
 
 impl UserService {
     pub async fn user_recommendations(&self, user_id: &String) -> Result<Vec<String>> {
-        let preferences = user_preferences_by_id(user_id, &self.0).await?;
+        let preferences = user_by_id(user_id, &self.0).await?.preferences;
         let limit = preferences
             .general
             .dashboard
@@ -215,7 +215,7 @@ impl UserService {
         admin_user_id: String,
         to_delete_user_id: String,
     ) -> Result<bool> {
-        admin_account_guard(&self.0.db, &admin_user_id).await?;
+        admin_account_guard(&admin_user_id, &self.0).await?;
         let maybe_user = User::find_by_id(to_delete_user_id).one(&self.0.db).await?;
         let Some(u) = maybe_user else {
             return Ok(false);
@@ -399,7 +399,7 @@ impl UserService {
         input: UpdateComplexJsonInput,
     ) -> Result<bool> {
         let err = || Error::new("Incorrect property value encountered");
-        let user_model = user_by_id(&self.0.db, &user_id).await?;
+        let user_model = user_by_id(&user_id, &self.0).await?;
         let mut preferences = user_model.preferences.clone();
         match input.property.is_empty() {
             true => preferences = UserPreferences::default(),
@@ -977,8 +977,7 @@ impl UserService {
                 error: UserDetailsErrorVariant::AuthTokenInvalid,
             }));
         };
-        let mut user = user_by_id(&self.0.db, &user_id).await?;
-        user.preferences = user_preferences_by_id(&user_id, &self.0).await?;
+        let user = user_by_id(&user_id, &self.0).await?;
         Ok(UserDetailsResult::Ok(Box::new(user)))
     }
 
diff --git a/crates/utils/database/src/lib.rs b/crates/utils/database/src/lib.rs
index 0623a6345f..92a4703598 100644
--- a/crates/utils/database/src/lib.rs
+++ b/crates/utils/database/src/lib.rs
@@ -46,7 +46,7 @@ use sea_orm::{
 };
 use serde::{Deserialize, Serialize};
 use supporting_service::SupportingService;
-use user_models::{UserPreferences, UserReviewScale};
+use user_models::UserReviewScale;
 use uuid::Uuid;
 
 pub async fn revoke_access_link(db: &DatabaseConnection, access_link_id: String) -> Result<bool> {
@@ -64,40 +64,32 @@ pub fn ilike_sql(value: &str) -> String {
     format!("%{value}%")
 }
 
-pub async fn user_by_id(db: &DatabaseConnection, user_id: &String) -> Result<user::Model> {
-    User::find_by_id(user_id)
-        .one(db)
-        .await
-        .unwrap()
-        .ok_or_else(|| Error::new("No user found"))
-}
-
-pub async fn user_preferences_by_id(
-    user_id: &String,
-    ss: &Arc<SupportingService>,
-) -> Result<UserPreferences> {
-    let mut preferences = user_by_id(&ss.db, user_id).await?.preferences;
-    preferences.features_enabled.media.anime =
-        ss.config.anime_and_manga.is_enabled() && preferences.features_enabled.media.anime;
-    preferences.features_enabled.media.audio_book =
-        ss.config.audio_books.is_enabled() && preferences.features_enabled.media.audio_book;
-    preferences.features_enabled.media.book =
-        ss.config.books.is_enabled() && preferences.features_enabled.media.book;
-    preferences.features_enabled.media.show =
-        ss.config.movies_and_shows.is_enabled() && preferences.features_enabled.media.show;
-    preferences.features_enabled.media.manga =
-        ss.config.anime_and_manga.is_enabled() && preferences.features_enabled.media.manga;
-    preferences.features_enabled.media.movie =
-        ss.config.movies_and_shows.is_enabled() && preferences.features_enabled.media.movie;
-    preferences.features_enabled.media.podcast =
-        ss.config.podcasts.is_enabled() && preferences.features_enabled.media.podcast;
-    preferences.features_enabled.media.video_game =
-        ss.config.video_games.is_enabled() && preferences.features_enabled.media.video_game;
-    Ok(preferences)
+pub async fn user_by_id(user_id: &String, ss: &Arc<SupportingService>) -> Result<user::Model> {
+    let mut user = User::find_by_id(user_id)
+        .one(&ss.db)
+        .await?
+        .ok_or_else(|| Error::new("No user found"))?;
+    let config = &ss.config;
+    let features_enabled = &mut user.preferences.features_enabled;
+    features_enabled.media.anime =
+        config.anime_and_manga.is_enabled() && features_enabled.media.anime;
+    features_enabled.media.audio_book =
+        config.audio_books.is_enabled() && features_enabled.media.audio_book;
+    features_enabled.media.book = config.books.is_enabled() && features_enabled.media.book;
+    features_enabled.media.show =
+        config.movies_and_shows.is_enabled() && features_enabled.media.show;
+    features_enabled.media.manga =
+        config.anime_and_manga.is_enabled() && features_enabled.media.manga;
+    features_enabled.media.movie =
+        config.movies_and_shows.is_enabled() && features_enabled.media.movie;
+    features_enabled.media.podcast = config.podcasts.is_enabled() && features_enabled.media.podcast;
+    features_enabled.media.video_game =
+        config.video_games.is_enabled() && features_enabled.media.video_game;
+    Ok(user)
 }
 
-pub async fn admin_account_guard(db: &DatabaseConnection, user_id: &String) -> Result<()> {
-    let main_user = user_by_id(db, user_id).await?;
+pub async fn admin_account_guard(user_id: &String, ss: &Arc<SupportingService>) -> Result<()> {
+    let main_user = user_by_id(user_id, ss).await?;
     if main_user.lot != UserLot::Admin {
         return Err(Error::new(BackendError::AdminOnlyAction.to_string()));
     }
@@ -401,12 +393,12 @@ pub async fn remove_entity_from_collection(
 }
 
 pub async fn item_reviews(
-    db: &DatabaseConnection,
     user_id: &String,
     entity_id: &String,
     entity_lot: EntityLot,
     // DEV: Setting this to true will return ALL user's reviews + public reviews by others
     get_public: bool,
+    ss: &Arc<SupportingService>,
 ) -> Result<Vec<ReviewItem>> {
     let column = match entity_lot {
         EntityLot::Metadata => review::Column::MetadataId,
@@ -426,7 +418,7 @@ pub async fn item_reviews(
         .find_also_related(User)
         .order_by_desc(review::Column::PostedOn)
         .filter(column.eq(entity_id))
-        .all(db)
+        .all(&ss.db)
         .await
         .unwrap();
     let mut reviews = vec![];
@@ -434,7 +426,7 @@ pub async fn item_reviews(
         let user = user.unwrap();
         let rating = match true {
             true => {
-                let preferences = user_by_id(db, user_id).await?.preferences;
+                let preferences = user_by_id(user_id, ss).await?.preferences;
                 review.rating.map(|s| {
                     s.checked_div(match preferences.general.review_scale {
                         UserReviewScale::OutOfFive => dec!(20),
@@ -453,7 +445,7 @@ pub async fn item_reviews(
             .column(seen::Column::Id)
             .filter(seen::Column::ReviewId.eq(&review.id))
             .into_tuple::<String>()
-            .all(db)
+            .all(&ss.db)
             .await?;
         let to_push = ReviewItem {
             rating,
diff --git a/crates/utils/dependent/src/lib.rs b/crates/utils/dependent/src/lib.rs
index 9cb5cf5982..c05a26ed4a 100644
--- a/crates/utils/dependent/src/lib.rs
+++ b/crates/utils/dependent/src/lib.rs
@@ -22,7 +22,6 @@ use database_models::{
 use database_utils::{
     add_entity_to_collection, admin_account_guard, create_or_update_collection,
     deploy_job_to_re_evaluate_user_workouts, remove_entity_from_collection, user_by_id,
-    user_preferences_by_id,
 };
 use dependent_models::ImportResult;
 use enums::{
@@ -670,6 +669,7 @@ pub async fn update_metadata(
                 ss,
             )
             .await?;
+            ryot_log!(debug, "Updated metadata for {:?}", metadata_id);
             notifications
         }
         Err(e) => {
@@ -682,7 +682,6 @@ pub async fn update_metadata(
             vec![]
         }
     };
-    ryot_log!(debug, "Updated metadata for {:?}", metadata_id);
     Ok(notifications)
 }
 
@@ -738,7 +737,7 @@ pub async fn queue_media_state_changed_notification_for_user(
     ss: &Arc<SupportingService>,
 ) -> Result<()> {
     let (msg, change) = notification;
-    let notification_preferences = user_preferences_by_id(user_id, ss).await?.notifications;
+    let notification_preferences = user_by_id(user_id, ss).await?.preferences.notifications;
     if notification_preferences.enabled && notification_preferences.to_send.contains(change) {
         queue_notifications_to_user_platforms(user_id, msg, &ss.db)
             .await
@@ -915,7 +914,7 @@ pub async fn deploy_background_job(
         | BackgroundJob::UpdateAllExercises
         | BackgroundJob::RecalculateCalendarEvents
         | BackgroundJob::PerformBackgroundTasks => {
-            admin_account_guard(&ss.db, user_id).await?;
+            admin_account_guard(user_id, ss).await?;
         }
         _ => {}
     }
@@ -971,7 +970,7 @@ pub async fn post_review(
     input: CreateOrUpdateReviewInput,
     ss: &Arc<SupportingService>,
 ) -> Result<StringIdObject> {
-    let preferences = user_preferences_by_id(user_id, ss).await?;
+    let preferences = user_by_id(user_id, ss).await?.preferences;
     if preferences.general.disable_reviews {
         return Err(Error::new("Reviews are disabled"));
     }
@@ -1061,7 +1060,7 @@ pub async fn post_review(
             EntityLot::Exercise => id.clone(),
             EntityLot::Workout | EntityLot::WorkoutTemplate => unreachable!(),
         };
-        let user = user_by_id(&ss.db, &insert.user_id.unwrap()).await?;
+        let user = user_by_id(&insert.user_id.unwrap(), ss).await?;
         // DEV: Do not send notification if updating a review
         if input.review_id.is_none() {
             ss.perform_core_application_job(CoreApplicationJob::ReviewPosted(ReviewPostedEvent {
@@ -1961,7 +1960,7 @@ pub async fn process_import(
     ss: &Arc<SupportingService>,
 ) -> Result<ImportResultResponse> {
     let mut import = import;
-    let preferences = user_by_id(&ss.db, user_id).await?.preferences;
+    let preferences = user_by_id(user_id, ss).await?.preferences;
     for m in import.metadata.iter_mut() {
         m.seen_history.sort_by(|a, b| {
             a.ended_on
diff --git a/docs/content/configuration.md b/docs/content/configuration.md
index b2758f3e69..f70b9367ab 100644
--- a/docs/content/configuration.md
+++ b/docs/content/configuration.md
@@ -1,26 +1,21 @@
 # Configuration
 
-You can specify configuration options via environment variables or via files (loaded from
-`config/ryot.json`, `config/ryot.toml`, `config/ryot.yaml`). They should be present in `/home/ryot/config/ryot.<ext>`.
+You can specify configuration options via environment variables. Each option is documented
+[below](#all-parameters) with what it does and a default (if any).
 
 Ryot serves the final configuration loaded at the `/backend/config` endpoint as JSON
-([example](https://pro.ryot.io/backend/config)).
-
-!!! info
-
-    The defaults can be inspected in the
-    [config]({{ extra.file_path }}/libs/config/src/lib.rs) builder.
+([example](https://pro.ryot.io/backend/config)). Sensitive variables are redacted.
 
 ## Important parameters
 
-| Key / Environment variable                                              | Description                                                                                                            |
-| ----------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
-| - / `PORT`                                                              | The port to listen on. Defaults to `8000`.                                                                             |
-| - / `TZ`                                                                | Timezone to be used for cron jobs. Accepts values according to the IANA database. Defaults to `GMT`.                   |
-| `disable_telemetry` / `DISABLE_TELEMETRY`                               | Disables telemetry collection using [Umami](https://umami.is). Defaults to `false`.                                    |
-| `database.url` / `DATABASE_URL`                                         | The Postgres database connection string.                                                                               |
-| `video_games.twitch.client_id` / `VIDEO_GAMES_TWITCH_CLIENT_ID`         | The client ID issued by Twitch. **Required** to enable video games tracking. [More information](guides/video-games.md) |
-| `video_games.twitch.client_secret` / `VIDEO_GAMES_TWITCH_CLIENT_SECRET` | The client secret issued by Twitch. **Required** to enable video games tracking.                                       |
+| Environment variable               | Description                                                                                                            |
+| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| `PORT`                             | The port to listen on. Defaults to `8000`.                                                                             |
+| `TZ`                               | Timezone to be used for cron jobs. Accepts values according to the IANA database. Defaults to `GMT`.                   |
+| `DISABLE_TELEMETRY`                | Disables telemetry collection using [Umami](https://umami.is). Defaults to `false`.                                    |
+| `DATABASE_URL`                     | The Postgres database connection string.                                                                               |
+| `VIDEO_GAMES_TWITCH_CLIENT_ID`     | The client ID issued by Twitch. **Required** to enable video games tracking. [More information](guides/video-games.md) |
+| `VIDEO_GAMES_TWITCH_CLIENT_SECRET` | The client secret issued by Twitch. **Required** to enable video games tracking.                                       |
 
 ## Health endpoint
 
diff --git a/docs/content/guides/openid.md b/docs/content/guides/authentication.md
similarity index 86%
rename from docs/content/guides/openid.md
rename to docs/content/guides/authentication.md
index f7e45875fc..712de2074c 100644
--- a/docs/content/guides/openid.md
+++ b/docs/content/guides/authentication.md
@@ -1,4 +1,9 @@
-# OpenID Authentication
+# Authentication
+
+Ryot supports multiple authentication methods. By default, it uses local authentication
+which means that you can log in using a username and password.
+
+## OpenID
 
 Ryot can be configured to use OpenID Connect (OIDC) for authentication. The following
 environment variables need to be set:
diff --git a/docs/content/guides/books.md b/docs/content/guides/books.md
index f0069ebdd7..627242dd2a 100644
--- a/docs/content/guides/books.md
+++ b/docs/content/guides/books.md
@@ -28,5 +28,5 @@ Google Books.
 
 6.  Click on "Create" and copy the API key.
 
-7. Set the `books.google_books.api_key` configuration variable in the environment as
-   described in the [configuration](../configuration.md#important-parameters) docs.
+7. Set the `BOOKS_GOOGLE_BOOKS_API_KEY` environment variable as described in the
+   [configuration](../configuration.md#important-parameters) docs.
diff --git a/docs/content/guides/exporting.md b/docs/content/guides/exporting.md
index 5d85479987..5c6be1005d 100644
--- a/docs/content/guides/exporting.md
+++ b/docs/content/guides/exporting.md
@@ -44,7 +44,13 @@ the entire database and emailing the file.
 docker exec -u postgres -i ryot-db pg_dump -Fc --no-acl --no-owner > /tmp/ryot.file.sql
 ```
 
-## Type definition
+To restore the above dump, run the following command:
+
+```bash
+docker exec -u postgres -i ryot-db pg_restore -U postgres -d postgres < /tmp/ryot.file.sql
+```
+
+## Type definitions
 
 ```ts
 {% include 'export-schema.ts' %}
diff --git a/docs/content/guides/video-games.md b/docs/content/guides/video-games.md
index b57798c42a..2095a3e33a 100644
--- a/docs/content/guides/video-games.md
+++ b/docs/content/guides/video-games.md
@@ -26,5 +26,5 @@ You can follow the below steps to obtain your own API keys and enable video game
 
 6. Generate a client secret. Copy the **Client ID** and **Client Secret**.
 
-7. Set the `video_games.*` configuration variables in the environment as
-   described in the [configuration](../configuration.md#important-parameters) docs.
+7. Set the `VIDEO_GAMES_*` environment variables as described in the
+   [configuration](../configuration.md#important-parameters) docs.
diff --git a/docs/content/importing.md b/docs/content/importing.md
index e6d3f95a4d..ab92f78de6 100644
--- a/docs/content/importing.md
+++ b/docs/content/importing.md
@@ -209,7 +209,7 @@ whose data you want to import.
 
 The "Generic Json" can be used to import all possible data from a generic JSON file. The
 format of the JSON file should be `CompleteExport` as described in the
-[exporting](guides/exporting.md#type-definition) documentation.
+[exporting](guides/exporting.md#type-definitions) documentation.
 
 You can use this to export all your data from one Ryot instance and import it into another,
 or from a source that is not supported by Ryot.
diff --git a/docs/content/index.md b/docs/content/index.md
index bddc15b6d2..aa7b0bf4ba 100644
--- a/docs/content/index.md
+++ b/docs/content/index.md
@@ -1,5 +1,7 @@
 # Installation
 
+Use the following docker-compose file:
+
 ```yaml
 services:
   ryot-db:
@@ -19,6 +21,7 @@ services:
     environment:
       - DATABASE_URL=postgres://postgres:postgres@ryot-db:5432/postgres
       - TZ=Europe/Amsterdam
+      - SERVER_ADMIN_ACCESS_TOKEN=28ebb3ae554fa9867ba0 # CHANGE THIS
     ports:
       - "8000:8000"
     pull_policy: always
@@ -29,13 +32,12 @@ volumes:
 ```
 
 If you would like to run the pro version, please check [below](#upgrading-to-pro). To see
-the features of the pro version, check the [features page]({{extra.main_website_url
-}}).
+the features of the pro version, check the [features page]({{extra.main_website_url}}).
 
 ## Upgrading to Pro
 
 To upgrade to the pro version, you need to provide a `SERVER_PRO_KEY` environment variable.
-You can get a key by purchasing it from the [website]({{ extra.main_website_url }}).
+You can get a key by purchasing it from the [website]({{extra.main_website_url}}).
 
 Once you have the key, you can set it in the `docker-compose.yml` file:
 
diff --git a/docs/content/integrations.md b/docs/content/integrations.md
index c70a4a4b50..e5c3ceae0b 100644
--- a/docs/content/integrations.md
+++ b/docs/content/integrations.md
@@ -3,55 +3,11 @@
 Integrations can be used to continuously update your media progress or inform external
 services about changes. They can be of following types:
 
+- _Sink_: An external client publishes progress updates to the Ryot server.
 - _Yank_: Progress data is downloaded from an externally running server at a periodic
   interval.
-- _Sink_: An external client publishes progress updates to the Ryot server.
 - _Push_: Ryot sends data to an external service when an event occurs.
 
-## Yank integrations
-
-You can configure the interval at which the data is fetched from the external using the
-`integration.sync_every_minutes` configuration key. Defaults to `5` (minutes).
-
-### Audiobookshelf
-
-!!! warning
-
-      This will only import media that are in progress. Perform an
-      [import](./importing.md#audiobookshelf) if you want to import media that are finished.
-
-The [Audiobookshelf](https://www.audiobookshelf.org) integration can sync all media if they
-have a valid provider ID (Audible, ITunes or ISBN).
-
-1. Obtain an API token as described in the Audiobookshelf
-   [authentication](https://api.audiobookshelf.org/#authentication) docs.
-2. Go to your Ryot user settings and add the correct details as described in the
-   [yank](#yank-integrations) section.
-
-### Komga
-
-The [Komga](https://komga.org/) integration can sync all media if they
-have a valid metadata provider.
-
-#### Steps
-
-If you use [Komf](https://github.com/Snd-R/komf) or some similar metadata provider these
-urls will be populated automatically. If you don't use komf you will either need to
-manually add the manga to your collection or you can perform the following steps.
-
-1. Navigate to the manga
-2. Open the edit tab
-3. Navigate to the Links tab
-4. Create a link named `AniList` or `MyAnimeList` providing the respective url (not case-sensitive)
-
-Then perform these steps on Ryot
-
-1. Create the integration and select Komga as the source
-2. Provide your BaseURL. Should look something like this `http://komga.acme.com` or `http://127.0.0.1:25600`
-3. Provide your Username and Password.
-4. Provide your preferred metadata provider. Ryot will attempt the others if the preferred
-   is unavailable and will fallback to title search otherwise.
-
 ## Sink integrations
 
 These work via webhooks wherein an external service can inform Ryot about a change. All
@@ -144,33 +100,76 @@ TMDb ID attached to their metadata.
 
 The "Generic Json" can be used to import all possible data using a generic JSON data
 format. The format of the JSON file should be `CompleteExport` as described in the
-[exporting](guides/exporting.md#type-definition) documentation.
+[exporting](guides/exporting.md#type-definitions) documentation.
 
 You can use this to build integrations with other services that Ryot does not support
 natively.
 
+## Yank integrations
+
+You can configure the interval at which the data is fetched from the external source using
+the `INTEGRATION_SYNC_EVERY_MINUTES` environment variable. Defaults to `5`.
+
+### Audiobookshelf
+
+!!! warning
+
+      This will only import media that are in progress. Perform an
+      [import](./importing.md#audiobookshelf) if you want to import media that are finished.
+
+The [Audiobookshelf](https://www.audiobookshelf.org) integration can sync all media if they
+have a valid provider ID (Audible, ITunes or ISBN).
+
+1. Obtain an API token as described in the Audiobookshelf
+   [authentication](https://api.audiobookshelf.org/#authentication) docs.
+2. Go to your Ryot user settings and add the correct details as described in the
+   [yank](#yank-integrations) section.
+
+### Komga
+
+The [Komga](https://komga.org/) integration can sync all media if they
+have a valid metadata provider.
+
+#### Steps
+
+If you use [Komf](https://github.com/Snd-R/komf) or some similar metadata provider these
+urls will be populated automatically. If you don't, you will either need to manually add
+the manga to your collection or you can perform the following steps.
+
+1. Navigate to the manga and open the Edit tab
+3. Navigate to the Links tab
+4. Create a link named `AniList` or `MyAnimeList` providing the respective url (not case-sensitive)
+
+Then perform these steps on Ryot
+
+1. Create an integration and select Komga as the source
+2. Provide your Base URL. It should look something like this `http://komga.acme.com` or
+   `http://127.0.0.1:25600`
+3. Provide your Username and Password.
+4. Provide your preferred metadata provider. Ryot will attempt the others if the preferred
+   is unavailable and will fallback to title search otherwise.
+
 ## Push integrations
 
 You can enable the following push integrations:
 
 ### Radarr
 
-Automatically add movies in the selected collections to Radarr.
+Events: `Item added to collection`
 
 1. Obtain your Radarr API key by going to the Radarr general settings page.
 2. Fill the inputs in the integration settings page with the correct details.
 
 ### Sonarr
 
-Automatically add shows in the selected collections to Sonarr.
+Events: `Item added to collection`
 
 1. Obtain your Sonarr API key by going to the Sonarr general settings page.
 2. Fill the inputs in the integration settings page with the correct details.
 
 ### Jellyfin
 
-Automatically mark movies and shows as watched in Jellyfin when you mark them as watched
-in Ryot.
+Events: `Item marked as completed`
 
 1. While creating the integration, you will be asked to provide your Jellyfin username and
    password.
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 66cb5a030b..2ac4485008 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -4,7 +4,7 @@ site_name: Ryot Documentation
 site_url: https://docs.ryot.io
 site_description: Ryot Documentation
 repo_url: https://github.com/ignisda/ryot
-dev_addr: 0.0.0.0:8000
+dev_addr: 0.0.0.0:8200
 docs_dir: content
 
 theme:
diff --git a/libs/transactional/emails/PurchaseComplete.tsx b/libs/transactional/emails/PurchaseComplete.tsx
index 1acbb530e4..c3ed5da448 100644
--- a/libs/transactional/emails/PurchaseComplete.tsx
+++ b/libs/transactional/emails/PurchaseComplete.tsx
@@ -7,7 +7,7 @@ const proLink = "https://pro.ryot.io";
 type PurchaseCompleteEmailProps = {
 	planType: string;
 	renewOn?: string;
-	data:
+	details:
 		| { __typename: "self_hosted"; key: string }
 		| {
 				__typename: "cloud";
@@ -18,19 +18,19 @@ type PurchaseCompleteEmailProps = {
 const subject = "Thank you for buying Ryot!";
 
 const PurchaseCompleteEmail = (props: PurchaseCompleteEmailProps) =>
-	props.data ? (
+	props.details ? (
 		<Layout headingText={subject}>
 			<Text>
 				You have successfully purchased a {props.planType} plan for Ryot Pro (
-				{props.data.__typename}).{" "}
+				{props.details.__typename}).{" "}
 				{props.renewOn
 					? `Your subscription will renew on ${props.renewOn}.`
 					: null}
 			</Text>
 			<Text>
-				{props.data.__typename === "self_hosted" ? (
+				{props.details.__typename === "self_hosted" ? (
 					<>
-						Your Pro Key is <strong>{props.data.key}</strong>. Please follow{" "}
+						Your Pro Key is <strong>{props.details.key}</strong>. Please follow{" "}
 						<Link href="https://docs.ryot.io#upgrading-to-pro">these</Link>{" "}
 						instructions to install/upgrade Ryot with your key.
 					</>
@@ -38,12 +38,12 @@ const PurchaseCompleteEmail = (props: PurchaseCompleteEmailProps) =>
 					<>
 						Your account has been created on{" "}
 						<Link href={proLink}>{proLink}</Link> with{" "}
-						{isString(props.data.auth) ? (
-							`Google using the email ${props.data.auth}. Please login to get started`
+						{isString(props.details.auth) ? (
+							`Google using the email ${props.details.auth}. Please login to get started`
 						) : (
 							<>
-								the username <strong>{props.data.auth.username}</strong> and
-								password <strong>{props.data.auth.password}</strong>. Please
+								the username <strong>{props.details.auth.username}</strong> and
+								password <strong>{props.details.auth.password}</strong>. Please
 								login and change your password from the profile settings
 							</>
 						)}
diff --git a/libs/ts-utils/package.json b/libs/ts-utils/package.json
index 3fd78dd770..51ed72090f 100644
--- a/libs/ts-utils/package.json
+++ b/libs/ts-utils/package.json
@@ -4,9 +4,11 @@
 	"dependencies": {
 		"@conform-to/zod": "1.2.2",
 		"@ryot/generated": "workspace:*",
+		"clsx": "2.1.1",
 		"dayjs": "1.11.13",
 		"humanize-duration-ts": "2.1.1",
 		"lodash": "4.17.21",
+		"tailwind-merge": "2.5.4",
 		"tiny-invariant": "1.3.3"
 	},
 	"devDependencies": {
diff --git a/libs/ts-utils/src/index.ts b/libs/ts-utils/src/index.ts
index a40a7c49cd..d0d0dc8a46 100644
--- a/libs/ts-utils/src/index.ts
+++ b/libs/ts-utils/src/index.ts
@@ -1,5 +1,6 @@
 import { parseWithZod } from "@conform-to/zod";
-import dayjs from "dayjs";
+import { type ClassValue, clsx } from "clsx";
+import dayjs, { type Dayjs } from "dayjs";
 import {
 	HumanizeDuration,
 	HumanizeDurationLanguage,
@@ -25,6 +26,7 @@ import sortBy from "lodash/sortBy";
 import startCase from "lodash/startCase";
 import sum from "lodash/sum";
 import truncate from "lodash/truncate";
+import { twMerge } from "tailwind-merge";
 import invariant from "tiny-invariant";
 import type { ZodTypeAny, output } from "zod";
 
@@ -43,9 +45,8 @@ export const humanizeDuration = (
 /**
  * Format a `Date` into a Rust `NaiveDate`
  */
-export const formatDateToNaiveDate = (t: Date) => {
-	return dayjs(t).format("YYYY-MM-DD");
-};
+export const formatDateToNaiveDate = (t: Date | Dayjs) =>
+	dayjs(t).format("YYYY-MM-DD");
 
 /**
  * Generate initials for a given string.
@@ -53,10 +54,10 @@ export const formatDateToNaiveDate = (t: Date) => {
 export const getInitials = (name: string) => {
 	const rgx = new RegExp(/(\p{L}{1})\p{L}+/gu);
 	const initials = [...name.matchAll(rgx)];
-	const actuals = (
+	const actualValues = (
 		(initials.shift()?.[1] || "") + (initials.pop()?.[1] || "")
 	).toUpperCase();
-	return actuals;
+	return actualValues;
 };
 
 /**
@@ -86,6 +87,8 @@ export const getActionIntent = (request: Request) => {
 	return intent;
 };
 
+export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
+
 export {
 	camelCase,
 	cloneDeep,
diff --git a/yarn.lock b/yarn.lock
index c68992d7e3..d0176b9861 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6294,6 +6294,7 @@ __metadata:
     remix-development-tools: "npm:4.7.3"
     remix-routes: "npm:1.7.7"
     remix-utils: "npm:7.7.0"
+    tailwind-merge: "npm:2.5.4"
     tiny-invariant: "npm:1.3.3"
     ts-essentials: "npm:10.0.2"
     ts-pattern: "npm:5.5.0"
@@ -6361,9 +6362,11 @@ __metadata:
     "@conform-to/zod": "npm:1.2.2"
     "@ryot/generated": "workspace:*"
     "@types/lodash": "npm:4.17.13"
+    clsx: "npm:2.1.1"
     dayjs: "npm:1.11.13"
     humanize-duration-ts: "npm:2.1.1"
     lodash: "npm:4.17.21"
+    tailwind-merge: "npm:2.5.4"
     tiny-invariant: "npm:1.3.3"
   languageName: unknown
   linkType: soft
@@ -6426,6 +6429,7 @@ __metadata:
     tailwind-merge: "npm:2.5.4"
     tailwindcss: "npm:3.4.14"
     tailwindcss-animate: "npm:1.0.7"
+    tiny-invariant: "npm:1.3.3"
     ts-pattern: "npm:5.5.0"
     typescript: "npm:5.6.3"
     typescript-remix-routes-plugin: "npm:1.0.1"


Test patch: