feat: Implement company offer submission page and success notification

- Added CompanyOffer component for submitting company offers with validation.
- Integrated email sending functionality upon form submission.
- Created a success page for company registration confirmation.
- Introduced a reusable SuccessNotification component for displaying success messages.
- Updated account update functionality with new fields and validation.
- Enhanced user experience with back button and logo components.
- Added necessary database migrations for account updates.
This commit is contained in:
Danel Kungla
2025-06-26 16:05:37 +03:00
parent 15798fdfdf
commit 6aa3a27d44
55 changed files with 2340 additions and 4225 deletions

View File

@@ -26,7 +26,8 @@ export function usePersonalAccountData(
`
id,
name,
picture_url
picture_url,
last_name
`,
)
.eq('primary_owner_user_id', userId)

View File

@@ -211,7 +211,6 @@ async function TeamAccountPage(props: {
<div>
<div className={'flex flex-col gap-y-8'}>
<div className={'flex flex-col gap-y-2.5'}>
<Heading level={6}>Company Employees</Heading>

View File

@@ -4,6 +4,8 @@ import { useRouter } from 'next/navigation';
import type { Provider } from '@supabase/supabase-js';
import { useSupabase } from '@/packages/supabase/src/hooks/use-supabase';
import { isBrowser } from '@kit/shared/utils';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
@@ -20,6 +22,7 @@ export function SignInMethodsContainer(props: {
callback: string;
joinTeam: string;
returnPath: string;
updateAccount: string;
};
providers: {
@@ -28,13 +31,14 @@ export function SignInMethodsContainer(props: {
oAuth: Provider[];
};
}) {
const client = useSupabase();
const router = useRouter();
const redirectUrl = isBrowser()
? new URL(props.paths.callback, window?.location.origin).toString()
: '';
const onSignIn = () => {
const onSignIn = async (userId?: string) => {
// if the user has an invite token, we should join the team
if (props.inviteToken) {
const searchParams = new URLSearchParams({
@@ -45,8 +49,28 @@ export function SignInMethodsContainer(props: {
router.replace(joinTeamPath);
} else {
// otherwise, we should redirect to the return path
router.replace(props.paths.returnPath);
if (!userId) {
router.replace(props.paths.callback);
return;
}
try {
const { data: hasPersonalCode } = await client.rpc(
'has_personal_code',
{
account_id: userId,
},
);
if (hasPersonalCode) {
router.replace(props.paths.returnPath);
} else {
router.replace(props.paths.updateAccount);
}
} catch {
router.replace(props.paths.callback);
return;
}
}
};

View File

@@ -16,6 +16,7 @@ export function SignUpMethodsContainer(props: {
paths: {
callback: string;
appHome: string;
updateAccount: string;
};
providers: {

View File

@@ -0,0 +1,225 @@
'use client';
import Link from 'next/link';
import { User } from '@supabase/supabase-js';
import { ExternalLink } from '@/public/assets/external-link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { UpdateAccountSchema } from '../schemas/update-account.schema';
import { onUpdateAccount } from '../server/actions/update-account-actions';
export function UpdateAccountForm({ user }: { user: User }) {
const form = useForm({
resolver: zodResolver(UpdateAccountSchema),
mode: 'onChange',
defaultValues: {
firstName: '',
lastName: '',
personalCode: '',
email: user.email,
phone: '',
city: '',
weight: 0,
height: 0,
userConsent: false,
},
});
return (
<Form {...form}>
<form
className="flex flex-col gap-6 px-6 pt-10 text-left"
onSubmit={form.handleSubmit(onUpdateAccount)}
>
<FormField
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:firstName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:lastName'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="personalCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:personalCode'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:email'} />
</FormLabel>
<FormControl>
<Input {...field} disabled />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:phone'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="city"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:city'} />
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-between gap-4">
<FormField
name="weight"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:weight'} />
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="kg"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="height"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:formField:height'} />
</FormLabel>
<FormControl>
<Input
placeholder="cm"
type="number"
{...field}
value={field.value ?? ''}
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value),
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
name="userConsent"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center gap-2 pb-1">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>
<Trans i18nKey={'account:updateAccount:userConsentLabel'} />
</FormLabel>
</div>
<Link
href={''}
className="flex flex-row items-center gap-2 text-sm hover:underline"
target="_blank"
>
<ExternalLink />
<Trans i18nKey={'account:updateAccount:userConsentUrlTitle'} />
</Link>
</FormItem>
)}
/>
<Button disabled={form.formState.isSubmitting} type="submit">
<Trans i18nKey={'account:updateAccount:button'} />
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
export const UpdateAccountSchema = z.object({
firstName: z
.string({
required_error: 'First name is required',
})
.nonempty(),
lastName: z
.string({
required_error: 'Last name is required',
})
.nonempty(),
personalCode: z
.string({
required_error: 'Personal code is required',
})
.nonempty(),
email: z.string().email({
message: 'Email is required',
}),
phone: z
.string({
required_error: 'Phone number is required',
})
.nonempty(),
city: z.string().optional(),
weight: z
.number({
required_error: 'Weight is required',
invalid_type_error: 'Weight must be a number',
})
.gt(0, { message: 'Weight must be greater than 0' }),
height: z
.number({
required_error: 'Height is required',
invalid_type_error: 'Height must be a number',
})
.gt(0, { message: 'Height must be greater than 0' }),
userConsent: z.boolean().refine((val) => val === true, {
message: 'Must be true',
}),
});

View File

@@ -0,0 +1,44 @@
'use server';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import pathsConfig from '~/config/paths.config';
import { UpdateAccountSchema } from '../../schemas/update-account.schema';
import { createAuthApi } from '../api';
export interface AccountSubmitData {
firstName: string;
lastName: string;
personalCode: string;
email: string;
phone?: string;
city?: string;
weight: number | null;
height: number | null;
userConsent: boolean;
}
export const onUpdateAccount = enhanceAction(
async (params) => {
const client = getSupabaseServerClient();
const api = createAuthApi(client);
try {
await api.updateAccount(params);
console.log('SUCCESS', pathsConfig.auth.updateAccountSuccess);
} catch (err: unknown) {
if (err instanceof Error) {
console.warn('On update account error: ' + err.message);
}
console.warn('On update account error: ', err);
}
redirect(pathsConfig.auth.updateAccountSuccess);
},
{
schema: UpdateAccountSchema,
},
);

View File

@@ -0,0 +1,93 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { AccountSubmitData } from './actions/update-account-actions';
/**
* Class representing an API for interacting with user accounts.
* @constructor
* @param {SupabaseClient<Database>} client - The Supabase client instance.
*/
class AuthApi {
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name hasPersonalCode
* @description Check if given account ID has added personal code.
* @param id
*/
async hasPersonalCode(id: string) {
const { data, error } = await this.client.rpc('has_personal_code', {
account_id: id,
});
if (error) {
throw error;
}
return data;
}
/**
* @name updateAccount
* @description Update required fields for the account.
* @param data
*/
async updateAccount(data: AccountSubmitData) {
const {
data: { user },
} = await this.client.auth.getUser();
if (!user) {
throw new Error('User not authenticated');
}
const { error } = await this.client.rpc('update_account', {
p_name: data.firstName,
p_last_name: data.lastName,
p_personal_code: data.personalCode,
p_phone: data.phone || '',
p_city: data.city || '',
p_has_consent_personal_data: data.userConsent,
p_uid: user.id,
});
if (error) {
throw error;
}
if (data.height || data.weight) {
await this.updateAccountParams(data);
}
}
/**
* @name updateAccountParams
* @description Update account parameters.
* @param data
*/
async updateAccountParams(data: AccountSubmitData) {
const {
data: { user },
} = await this.client.auth.getUser();
if (!user) {
throw new Error('User not authenticated');
}
console.log('test', user, data);
const response = await this.client.from('account_params').insert({
account_id: user.id,
height: data.height,
weight: data.weight,
});
if (response.error) {
throw response.error;
}
}
}
export function createAuthApi(client: SupabaseClient<Database>) {
return new AuthApi(client);
}

View File

@@ -1,2 +1,3 @@
export * from './components/sign-up-methods-container';
export * from './schemas/password-sign-up.schema';
export * from './components/update-account-form';

View File

@@ -1,5 +1,5 @@
{
"extends": "@kit/tsconfig/base.json",
"extends": "../../../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},

View File

@@ -1 +1,3 @@
export * from './notifications-popover';
export * from './success-notification';
export * from './update-account-success-notification';

View File

@@ -0,0 +1,50 @@
import Image from 'next/image';
import Link from 'next/link';
import { MedReportLogo } from '@/components/med-report-logo';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans';
export const SuccessNotification = ({
showLogo = true,
title,
titleKey,
descriptionKey,
buttonProps,
}: {
showLogo?: boolean;
title?: string;
titleKey?: string;
descriptionKey?: string;
buttonProps?: {
buttonTitleKey: string;
href: string;
};
}) => {
return (
<div className="border-border rounded-3xl border px-16 pt-4 pb-12">
{showLogo && <MedReportLogo />}
<div className="flex flex-col items-center px-4">
<Image
src="/assets/success.png"
alt="Success"
className="pt-6 pb-8"
width={326}
height={195}
/>
<h1 className="pb-2">{title || <Trans i18nKey={titleKey} />}</h1>
<p className="text-muted-foreground text-sm">
<Trans i18nKey={descriptionKey} />
</p>
</div>
{buttonProps && (
<Button className="mt-8 w-full">
<Link href={buttonProps.href}>
<Trans i18nKey={buttonProps.buttonTitleKey} />
</Link>
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,39 @@
'use client';
import { redirect } from 'next/navigation';
import pathsConfig from '@/config/paths.config';
import { useTranslation } from 'react-i18next';
import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data';
import { SuccessNotification } from './success-notification';
export const UpdateAccountSuccessNotification = ({
userId,
}: {
userId?: string;
}) => {
const { t } = useTranslation('account');
if (!userId) {
redirect(pathsConfig.app.home);
}
const { data: accountData } = usePersonalAccountData(userId);
return (
<SuccessNotification
showLogo={false}
title={t('account:updateAccount:successTitle', {
firstName: accountData?.name,
lastName: accountData?.last_name,
})}
descriptionKey="account:updateAccount:successDescription"
buttonProps={{
buttonTitleKey: 'account:updateAccount:successButton',
href: pathsConfig.app.home,
}}
/>
);
};

View File

@@ -1,5 +1,5 @@
{
"extends": "@kit/tsconfig/base.json",
"extends": "../../../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},

File diff suppressed because it is too large Load Diff

View File

@@ -70,7 +70,7 @@ export function VersionUpdater(props: { intervalTimeInSecond?: number }) {
setDismissed(true);
}}
>
<Trans i18nKey="common:back" />
<Trans i18nKey="common:goBack" />
</Button>
<Button onClick={() => window.location.reload()}>

View File

@@ -12,7 +12,7 @@ const Checkbox: React.FC<
> = ({ className, ...props }) => (
<CheckboxPrimitive.Root
className={cn(
'peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-xs border shadow-xs focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
'peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-sm border shadow-xs focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}

View File

@@ -1,5 +1,5 @@
{
"extends": "@kit/tsconfig/base.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"paths": {
@@ -13,4 +13,4 @@
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
}