Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/profile/profile-picture-upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ interface ProfilePictureUploadProps {
name?: string;
disabled?: boolean;
userId?: string;
onUploaded?: (objectKey: string) => void;
}

export function ProfilePictureUpload({
currentImage,
name,
disabled = false,
userId,
onUploaded,
}: ProfilePictureUploadProps) {
const { user } = useAuth();

Expand All @@ -32,6 +34,7 @@ export function ProfilePictureUpload({
id={userId ?? user?.id ?? ""}
disabled={disabled}
targetSize={120}
onUploaded={onUploaded}
// onUploaded={(objectKey) => {
// const base = process.env.NEXT_PUBLIC_MINIO_PUBLIC_URL!;
// const bucket = process.env.NEXT_PUBLIC_MINIO_BUCKET!;
Expand Down
228 changes: 227 additions & 1 deletion src/components/settings/pages/profile-settings-content.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,229 @@
"use client";

import { useEffect, useState } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { Button } from "@/components/primitives/button";
import { Field, FieldLabel, FieldError } from "@/components/primitives/field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/primitives/card";
import { Input } from "@/components/primitives/input";
import { Textarea } from "@/components/primitives/textarea";
import { Alert, AlertTitle, AlertDescription } from "@/components/primitives/alert";
import { CheckCircle2, AlertCircle } from "lucide-react";

import { Spinner } from "@/components/primitives/spinner";

import { WithPermission } from "@/components/utils/with-permission";
import { ProfilePictureUpload } from "@/components/profile/profile-picture-upload";
import { useAuth } from "@/providers/client-auth-provider";
import { clientApi } from "@/trpc/client";
import { authClient } from "@/lib/auth/client";

const ProfileSchema = z.object({
firstName: z.string().nonempty("Please enter your first name."),
lastName: z.string().nonempty("Please enter your last name."),
email: z
.string()
.email("Please enter a valid email address.")
.nonempty("Email is required."),
preferredName: z.string().optional(),
pronouns: z.string().optional(),
bio: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
profilePictureUrl: z.string().optional(),
});

type ProfileSchemaType = z.infer<typeof ProfileSchema>;

export function ProfileSettingsContent() {
return <>Profile coming soon...</>;
const { user } = useAuth();
const { refetch: refetchSession } = authClient.useSession();


const { data: volunteer } = clientApi.volunteer.byId.useQuery(
{ volunteerUserId: user!.id },
{ enabled: !!user }
);
const updateProfile = clientApi.volunteer.updateVolunteerProfile.useMutation({
onSuccess: async () => {
refetchSession(); // so we can keep user-level changes up to date
},
});
const [successMessage, setSuccessMessage] = useState<string | null>(null);

const {
register,
handleSubmit,
reset,
setValue,
setError,
formState: { errors, isSubmitting },
} = useForm<ProfileSchemaType>({
resolver: zodResolver(ProfileSchema),
mode: "onSubmit",
reValidateMode: "onChange",
defaultValues: {
firstName: user?.name ?? "",
lastName: user?.lastName ?? "",
email: user?.email ?? "",
preferredName: "",
pronouns: "",
bio: "",
city: "",
province: "",
profilePictureUrl: undefined,
},
});

useEffect(() => {
if (!volunteer) return;

reset({
firstName: user?.name ?? "",
lastName: user?.lastName ?? "",
email: user?.email ?? "",
preferredName: volunteer.preferredName ?? "",
pronouns: volunteer.pronouns ?? "",
bio: volunteer.bio ?? "",
city: volunteer.city ?? "",
province: volunteer.province ?? "",
profilePictureUrl: undefined,
});
}, [volunteer, user, reset]);

const onSubmit = async (data: ProfileSchemaType) => {
if (!user?.id) return;

const result = await updateProfile.mutateAsync({
volunteerUserId: user.id,
...data,
});

if (!result.ok) {
setError("root", {
type: "custom",
message: "Something went wrong.",
});
return;
}

setSuccessMessage("Your profile has been successfully updated!");
};

return (
<WithPermission permissions={{ permission: { profile: ["update"] } }}>
{errors.root?.message && (
<Alert variant="destructive" role="alert" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Couldn’t update profile</AlertTitle>
<AlertDescription>{errors.root.message}</AlertDescription>
</Alert>
)}

{successMessage && (
<Alert variant="success" role="status" aria-live="polite">
<CheckCircle2 className="h-4 w-4" />
<AlertTitle>Success</AlertTitle>
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
)}

<form onSubmit={handleSubmit(onSubmit)} className="space-y-6" noValidate>
<Card className="shadow-sm flex">
<CardHeader>
<CardTitle>Profile Information</CardTitle>
</CardHeader>

<CardContent className="grid gap-4">
<div className="flex gap-8">
<div className="flex flex-col gap-2 justify-start">
<FieldLabel>Profile Picture</FieldLabel>
<div className="flex">
<ProfilePictureUpload
currentImage={undefined}
name={`${user?.name ?? ""} ${user?.lastName ?? ""}`}
userId={user?.id}
disabled={false}
onUploaded={(objectKey) => {
const base = process.env.NEXT_PUBLIC_MINIO_PUBLIC_URL!;
const bucket = process.env.NEXT_PUBLIC_MINIO_BUCKET!;
const imageUrl = `${base}/${bucket}/${objectKey}`;
setValue("profilePictureUrl", imageUrl);
}}
/>
</div>
</div>

<div className="flex flex-grow flex-col gap-4">
<Field data-invalid={!!errors.firstName}>
<FieldLabel htmlFor="firstName">First Name</FieldLabel>
<Input id="firstName" {...register("firstName")} />
<FieldError errors={errors.firstName} />
</Field>

<Field data-invalid={!!errors.lastName}>
<FieldLabel htmlFor="lastName">Last Name</FieldLabel>
<Input id="lastName" {...register("lastName")} />
<FieldError errors={errors.lastName} />
</Field>
</div>
</div>

<Field data-invalid={!!errors.email}>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" {...register("email")} />
<FieldError errors={errors.email} />
</Field>

<Field data-invalid={!!errors.preferredName}>
<FieldLabel htmlFor="preferredName">Preferred Name</FieldLabel>
<Input id="preferredName" {...register("preferredName")} />
<FieldError errors={errors.preferredName} />
</Field>

<Field data-invalid={!!errors.pronouns}>
<FieldLabel htmlFor="pronouns">Pronouns</FieldLabel>
<Input id="pronouns" {...register("pronouns")} />
<FieldError errors={errors.pronouns} />
</Field>

<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.city}>
<FieldLabel htmlFor="city">City</FieldLabel>
<Input id="city" {...register("city")} />
<FieldError errors={errors.city} />
</Field>

<Field data-invalid={!!errors.province}>
<FieldLabel htmlFor="province">Province</FieldLabel>
<Input id="province" {...register("province")} />
<FieldError errors={errors.province} />
</Field>
</div>

<Field data-invalid={!!errors.bio}>
<FieldLabel htmlFor="bio">Bio</FieldLabel>
<Textarea id="bio" {...register("bio")} />
<FieldError errors={errors.bio} />
</Field>

<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-fit">
{isSubmitting ? (
<>
<Spinner /> Saving...
</>
) : (
"Save Changes"
)}
</Button>
</div>
</CardContent>
</Card>
</form>
</WithPermission>
);
}
6 changes: 3 additions & 3 deletions src/components/settings/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const SettingsDialog = NiceModal.create(() => {
>
<Tabs
defaultValue="profile"
className="md:grid md:grid-cols-[180px_1fr]"
className="md:grid md:grid-cols-[180px_1fr] overflow-auto"
>
<header className="md:hidden flex items-center justify-between h-13 px-3">
<DialogTitle>Settings</DialogTitle>
Expand Down Expand Up @@ -111,7 +111,7 @@ export const SettingsDialog = NiceModal.create(() => {

{settingsItems.map((item) => (
<TabsContent
className="px-4 flex flex-col gap-6"
className="px-4 flex flex-col gap-4"
key={item.id}
value={item.id}
>
Expand All @@ -121,7 +121,7 @@ export const SettingsDialog = NiceModal.create(() => {
<DialogDescription>{item.description}</DialogDescription>
)}
</DialogHeader>
This is the {item.id} page
<item.content />
</TabsContent>
))}
</Tabs>
Expand Down
13 changes: 1 addition & 12 deletions src/models/api/volunteer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,12 @@ export const VolunteerIdInput = z.object({
volunteerUserId: z.uuid(),
});

export const UpdateVolunteerProfileInput = z.object({
volunteerUserId: z.uuid(),
preferredName: z.string().optional(),
bio: z.string().optional(),
pronouns: z.string().optional(),
phoneNumber: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
preferredTimeCommitmentHours: z.number().int().min(0).optional(),
});

export const UpdateVolunteerAvailabilityInput = z.object({
volunteerUserId: z.uuid(),
availability: BitString(AVAILABILITY_SLOTS),
});

export const UpdateVolunteerInput = z.object({
export const UpdateVolunteerProfileInput = z.object({
volunteerUserId: z.uuid(),
firstName: z.string().optional(),
lastName: z.string().optional(),
Expand Down
11 changes: 5 additions & 6 deletions src/server/api/routers/volunteer-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,12 @@ export const volunteerRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
return await ctx.volunteerService.getClassPreference(input.volunteerUserId, input.classId)
}),
byId: authorizedProcedure({ permission: { users: ["view-volunteer"] } })
byId: authorizedProcedure({
permission: { profile: ["view"] } }
)
.input(VolunteerIdInput)
.query(async ({ input }) => {
// TODO: getVolunteerById
return {
/* volunteer */
};
.query(async ({ input, ctx }) => {
return await ctx.volunteerService.getVolunteer(input.volunteerUserId);
}),
updateVolunteerProfile: authorizedProcedure({
permission: { profile: ["update"] },
Expand Down
Loading