feature: onboarding emails

* wip

* wip

* wip

* fix coderabbit comments

* remove template
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-22 10:38:05 +01:00
committed by GitHub
parent 67301d928c
commit e645c094b2
43 changed files with 1604 additions and 114 deletions

View File

@@ -0,0 +1,139 @@
import { WithLabel } from '@/components/forms/input-with-label';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { handleError } from '@/integrations/trpc/react';
import { emailCategories } from '@openpanel/constants';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { SaveIcon } from 'lucide-react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const validator = z.object({
categories: z.record(z.string(), z.boolean()),
});
type IForm = z.infer<typeof validator>;
/**
* Build explicit boolean values for every key in emailCategories.
* Uses saved preferences when available, falling back to true (opted-in).
*/
function buildCategoryDefaults(
savedPreferences?: Record<string, boolean>,
): Record<string, boolean> {
return Object.keys(emailCategories).reduce(
(acc, category) => {
acc[category] = savedPreferences?.[category] ?? true;
return acc;
},
{} as Record<string, boolean>,
);
}
export const Route = createFileRoute(
'/_app/$organizationId/profile/_tabs/email-preferences',
)({
component: Component,
pendingComponent: FullPageLoadingState,
});
function Component() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const preferencesQuery = useSuspenseQuery(
trpc.email.getPreferences.queryOptions(),
);
const { control, handleSubmit, formState, reset } = useForm<IForm>({
defaultValues: {
categories: buildCategoryDefaults(preferencesQuery.data),
},
});
const mutation = useMutation(
trpc.email.updatePreferences.mutationOptions({
onSuccess: async () => {
toast('Email preferences updated', {
description: 'Your email preferences have been saved.',
});
await queryClient.invalidateQueries(
trpc.email.getPreferences.pathFilter(),
);
// Reset form with fresh data after refetch
const freshData = await queryClient.fetchQuery(
trpc.email.getPreferences.queryOptions(),
);
reset({
categories: buildCategoryDefaults(freshData),
});
},
onError: handleError,
}),
);
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Email Preferences</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<p className="text-sm text-muted-foreground mb-4">
Choose which types of emails you want to receive. Uncheck a category
to stop receiving those emails.
</p>
<div className="space-y-4">
{Object.entries(emailCategories).map(([category, label]) => (
<Controller
key={category}
name={`categories.${category}`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-between gap-4 px-4 py-4 rounded-md border border-border hover:bg-def-200 transition-colors">
<div className="flex-1">
<div className="font-medium">{label}</div>
<div className="text-sm text-muted-foreground">
{category === 'onboarding' &&
'Get started tips and guidance emails'}
{category === 'billing' &&
'Subscription updates and payment reminders'}
</div>
</div>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={mutation.isPending}
/>
</div>
)}
/>
))}
</div>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty || mutation.isPending}
className="self-end mt-4"
icon={SaveIcon}
loading={mutation.isPending}
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,96 @@
import { InputWithLabel } from '@/components/forms/input-with-label';
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { SaveIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const validator = z.object({
firstName: z.string(),
lastName: z.string(),
});
type IForm = z.infer<typeof validator>;
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs/')({
component: Component,
pendingComponent: FullPageLoadingState,
});
function Component() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const session = useSuspenseQuery(trpc.auth.session.queryOptions());
const user = session.data?.user;
const { register, handleSubmit, formState, reset } = useForm<IForm>({
defaultValues: {
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
},
});
const mutation = useMutation(
trpc.user.update.mutationOptions({
onSuccess: (data) => {
toast('Profile updated', {
description: 'Your profile has been updated.',
});
queryClient.invalidateQueries(trpc.auth.session.pathFilter());
reset({
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
});
},
onError: handleError,
}),
);
if (!user) {
return null;
}
return (
<form
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
>
<Widget className="max-w-screen-md w-full">
<WidgetHead>
<span className="title">Profile</span>
</WidgetHead>
<WidgetBody className="gap-4 col">
<InputWithLabel
label="First name"
{...register('firstName')}
defaultValue={user.firstName ?? ''}
/>
<InputWithLabel
label="Last name"
{...register('lastName')}
defaultValue={user.lastName ?? ''}
/>
<Button
size="sm"
type="submit"
disabled={!formState.isDirty || mutation.isPending}
className="self-end"
icon={SaveIcon}
loading={mutation.isPending}
>
Save
</Button>
</WidgetBody>
</Widget>
</form>
);
}

View File

@@ -0,0 +1,55 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePageTabs } from '@/hooks/use-page-tabs';
import { useTRPC } from '@/integrations/trpc/react';
import { getProfileName } from '@/utils/getters';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Outlet, createFileRoute, useRouter } from '@tanstack/react-router';
export const Route = createFileRoute('/_app/$organizationId/profile/_tabs')({
component: Component,
pendingComponent: FullPageLoadingState,
});
function Component() {
const router = useRouter();
const { activeTab, tabs } = usePageTabs([
{
id: '/$organizationId/profile',
label: 'Profile',
},
{ id: 'email-preferences', label: 'Email preferences' },
]);
const handleTabChange = (tabId: string) => {
router.navigate({
from: Route.fullPath,
to: tabId,
});
};
return (
<PageContainer>
<PageHeader title={'Your profile'} />
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="mt-2 mb-8"
>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Outlet />
</PageContainer>
);
}

View File

@@ -0,0 +1,89 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import { PublicPageCard } from '@/components/public-page-card';
import { Button, LinkButton } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { emailCategories } from '@openpanel/constants';
import { useMutation } from '@tanstack/react-query';
import { createFileRoute, useSearch } from '@tanstack/react-router';
import { useState } from 'react';
import { z } from 'zod';
const unsubscribeSearchSchema = z.object({
email: z.string().email(),
category: z.string(),
token: z.string(),
});
export const Route = createFileRoute('/unsubscribe')({
component: RouteComponent,
validateSearch: unsubscribeSearchSchema,
pendingComponent: FullPageLoadingState,
});
function RouteComponent() {
const search = useSearch({ from: '/unsubscribe' });
const { email, category, token } = search;
const trpc = useTRPC();
const [isUnsubscribing, setIsUnsubscribing] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const unsubscribeMutation = useMutation(
trpc.email.unsubscribe.mutationOptions({
onSuccess: () => {
setIsSuccess(true);
setIsUnsubscribing(false);
},
onError: (err) => {
setError(err.message || 'Failed to unsubscribe');
setIsUnsubscribing(false);
},
}),
);
const handleUnsubscribe = () => {
setIsUnsubscribing(true);
setError(null);
unsubscribeMutation.mutate({ email, category, token });
};
const categoryName =
emailCategories[category as keyof typeof emailCategories] || category;
if (isSuccess) {
return (
<PublicPageCard
title="Unsubscribed"
description={`You've been unsubscribed from ${categoryName} emails. You won't receive any more ${categoryName.toLowerCase()} emails from
us.`}
/>
);
}
return (
<PublicPageCard
title="Unsubscribe"
description={
<>
Unsubscribe from {categoryName} emails? You'll stop receiving{' '}
{categoryName.toLowerCase()} emails sent to&nbsp;
<span className="">{email}</span>
</>
}
>
<div className="col gap-3">
{error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md text-sm">
{error}
</div>
)}
<Button onClick={handleUnsubscribe} disabled={isUnsubscribing}>
{isUnsubscribing ? 'Unsubscribing...' : 'Confirm Unsubscribe'}
</Button>
<LinkButton href="/" variant="ghost">
Cancel
</LinkButton>
</div>
</PublicPageCard>
);
}