diff --git a/.cursor/rules/basics.mdc b/.cursor/rules/basics.mdc index 698e44116..ff7b506e4 100644 --- a/.cursor/rules/basics.mdc +++ b/.cursor/rules/basics.mdc @@ -7,4 +7,5 @@ alwaysApply: true - Command for running script in a workspace: `pnpm --filter `. - Command for running tests: `pnpm test`. - The project uses shadcn for building UI so stick to its conventions and design. -- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. \ No newline at end of file +- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings. +- When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button. \ No newline at end of file diff --git a/apps/docs/public/assets/communities/delete-community.png b/apps/docs/public/assets/communities/delete-community.png new file mode 100644 index 000000000..c4ee06826 Binary files /dev/null and b/apps/docs/public/assets/communities/delete-community.png differ diff --git a/apps/docs/public/assets/users/delete-user.png b/apps/docs/public/assets/users/delete-user.png new file mode 100644 index 000000000..2fbc09f44 Binary files /dev/null and b/apps/docs/public/assets/users/delete-user.png differ diff --git a/apps/docs/src/config.ts b/apps/docs/src/config.ts index 27d48ddad..5406a66b4 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -79,6 +79,7 @@ export const SIDEBAR: Sidebar = { text: "Unlock additional products", link: "en/communities/grant-access-to-additional-products", }, + { text: "Delete a community", link: "en/communities/delete" }, ], "Email marketing and automation": [ { text: "Introduction", link: "en/email-marketing/introduction" }, @@ -122,6 +123,7 @@ export const SIDEBAR: Sidebar = { { text: "User permissions", link: "en/users/permissions" }, { text: "Filter users", link: "en/users/filters" }, { text: "Segment users", link: "en/users/segments" }, + { text: "Delete a user", link: "en/users/delete" }, ], Developers: [ { text: "Introduction", link: "en/developers/introduction" }, diff --git a/apps/docs/src/pages/en/communities/delete.md b/apps/docs/src/pages/en/communities/delete.md new file mode 100644 index 000000000..7de751c94 --- /dev/null +++ b/apps/docs/src/pages/en/communities/delete.md @@ -0,0 +1,201 @@ +--- +title: Delete a Community +description: Guide to safely deleting communities and all associated data +layout: ../../../layouts/MainLayout.astro +--- + +Deleting a community is a permanent operation that removes the community and all its associated data from your school. This includes all posts, comments, members, and payment plans. + +> **Important**: Community deletion is permanent and cannot be undone. All community content, memberships, and payment plans will be permanently removed. + +## How Community Deletion Works + +When you delete a community, CourseLit performs a comprehensive cleanup: + +1. **Content Deletion**: Removes all posts, comments, and reports +2. **Membership Cleanup**: Cancels all memberships and payment plans +3. **Page Removal**: Deletes the community's public page +4. **Media Cleanup**: Removes all associated media files +5. **Community Document**: Deletes the community itself + +## Prerequisites + +To delete a community, you must have the `Manage Community` permission. + +> **Note**: Even community moderators cannot delete a community. Only users with the `Manage Community` permission (typically site admins) can perform this operation. + +## Deleting a Community + +1. Navigate to the **Communities** area from your admin dashboard +2. Select the community you want to delete +3. Click on **Manage** to open settings +4. Scroll down to the Danger zone and click on **Delete Community** button + + ![Delete community](/assets/communities/delete-community.png) + +5. Confirm the deletion when prompted + +## What Gets Deleted + +### Community Content + +All content within the community is permanently removed: + +- **Posts**: All posts created in the community +- **Comments**: All comments on posts, including nested replies +- **Reports**: All content reports filed by members +- **Media**: All images, videos, and files uploaded to posts (when media uploads are enabled) + +### Memberships & Subscriptions + +All membership-related data is removed: + +- **Community Memberships**: All member records for the community +- **Payment Subscriptions**: All active subscriptions are automatically cancelled +- **Payment Plans**: All payment plans associated with the community +- **Included Product Memberships**: If the community's payment plans included access to courses, those memberships are also removed +- **Post Subscriptions**: All user subscriptions to community posts + +### Community Infrastructure + +The community's infrastructure is removed: + +- **Community Page**: The public-facing community page +- **Community Settings**: All configuration and settings +- **Categories**: All community categories +- **Featured Images**: Community banner and featured images + +### Related Data + +Additional data associated with the community: + +- **Activities**: Activity logs related to payment plan enrollments +- **Notifications**: Notifications related to the community (for members) + +## What Happens to Members + +When a community is deleted: + +1. **Active Subscriptions**: All payment subscriptions are automatically cancelled through your payment provider (Stripe, PayPal, etc.) +2. **Membership Records**: All membership records are permanently deleted +3. **Access Revoked**: Members immediately lose access to the community +4. **Included Products**: If members had access to courses through the community's payment plan, that access is also revoked + +## Payment Plan Considerations + +### Subscription Cancellations + +- All active subscriptions are cancelled automatically +- Payment providers (Stripe, PayPal, etc.) are notified +- No further charges will occur +- Members will not receive refunds automatically + +### Included Products + +If your community's payment plans [included access to courses](/en/communities/grant-access-to-additional-products) or other products: + +- All memberships to those products (activated through the community plan) are removed +- Activity logs for those memberships are deleted +- Direct purchases of those products (not through the community) are not affected + +## Media Cleanup + +The deletion process handles media files appropriately: + +- **Community Images**: Featured images and banners are deleted +- **Post Media**: When media uploads are enabled, all media from posts is deleted +- **User Avatars**: Not affected (user avatars are tied to user accounts, not communities) + +> **Note**: Currently, media uploads in community posts are not enabled. When this feature is activated, the deletion process will handle post media cleanup automatically. + +## Safety Measures + +CourseLit implements safety measures to ensure proper deletion: + +1. **Permission Check**: Only users with `Manage Community` permission can delete +2. **Confirmation Required**: Deletion requires explicit confirmation +3. **Atomic Operation**: The entire deletion succeeds or fails as a unit +4. **Subscription Cancellation**: Automatic cancellation prevents future charges + +## Before Deleting a Community + +Consider these steps before deleting: + +1. **Notify Members**: Inform community members about the upcoming deletion +2. **Export Data**: If you need to preserve any content, export it manually. Only works for self-hosted installations. +3. **Handle Refunds**: Process any necessary refunds through your payment provider +4. **Alternative Actions**: Consider making the community private instead of deleting it + +## After Deletion + +After a community is deleted: + +- The community page returns a 404 error +- Members cannot access the community anymore +- All content is permanently lost +- Payment subscriptions are cancelled +- The community name becomes available for reuse + +## Handling Refunds + +Community deletion does not automatically issue refunds. To handle refunds: + +1. **Before Deletion**: Note all active subscriptions and their subscription IDs +2. **Access Payment Provider**: Log into your Stripe, PayPal, or other payment provider dashboard +3. **Process Refunds**: Manually issue refunds as appropriate +4. **Delete Community**: Once refunds are processed, proceed with deletion + +## Alternative to Deletion + +If you want to preserve content but stop new members from joining: + +1. **Disable the Community**: Toggle the community to "disabled" in settings +2. **Remove Payment Plans**: Archive all payment plans + +This approach preserves content while preventing new access. + +## Troubleshooting + +### Cannot Delete Community + +If you encounter errors: + +- **"Action not allowed"**: You don't have the `Manage Community` permission +- **"Item not found"**: The community may have already been deleted or doesn't exist +- **"Community not found"**: You may not have access to this community + +### Subscription Cancellation Issues + +If subscriptions fail to cancel: + +1. Manually cancel subscriptions in your payment provider's dashboard +2. Contact support if issues persist + +### Partial Deletion + +If deletion fails partway through: + +- The operation is designed to be atomic, but in rare cases, partial deletion may occur +- Contact support with error details +- Manual cleanup may be required + +## Best Practices + +1. **Communicate Early**: Give members advance notice before deletion +2. **Export Important Content**: Save any valuable discussions or content +3. **Process Refunds First**: Handle refunds before deleting to maintain records +4. **Document the Decision**: Keep records of why and when the community was deleted +5. **Consider Alternatives**: Evaluate if disabling is sufficient instead of deletion + +## GDPR and Data Protection + +Community deletion helps with data protection compliance: + +- All member data within the community is removed +- Personal information in posts and comments is deleted +- Membership records are permanently erased +- The operation can be part of a broader data cleanup strategy + +## Stuck somewhere? + +We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/docs/src/pages/en/users/delete.md b/apps/docs/src/pages/en/users/delete.md new file mode 100644 index 000000000..440cfe944 --- /dev/null +++ b/apps/docs/src/pages/en/users/delete.md @@ -0,0 +1,169 @@ +--- +title: Delete a User +description: Guide to safely deleting users with GDPR compliance +layout: ../../../layouts/MainLayout.astro +--- + +Deleting a user is a critical operation that removes all personal data associated with that user from your school. This feature is designed to comply with GDPR and other data protection regulations. + +> **Important**: User deletion is permanent and cannot be undone. All personal data will be removed, and business entities will be transferred to the admin performing the deletion. + +## How User Deletion Works + +When you delete a user, CourseLit performs two main operations: + +1. **Business Entity Migration**: Transfers ownership of courses, communities, email templates, and other business-critical resources to the admin performing the deletion +2. **Personal Data Cleanup**: Permanently removes all personal data associated with the user + +## Prerequisites + +To delete a user, you must have the `Manage Users` permission. Additionally: + +- You cannot delete yourself +- You cannot delete the last user with critical permissions (like `Manage Site`, `Manage Users`, etc.) +- The system ensures at least one admin remains with each critical permission + +## Deleting a User + +1. Navigate to the **Users** area from the dashboard +2. Click on the user you want to delete to open their details +3. Scroll down to the Danger zone and click on **Delete user** button + + ![Delete user](/assets/users/delete-user.png) + +4. Confirm the deletion when prompted + +## What Gets Migrated + +The following business entities are transferred to the admin performing the deletion: + +### Courses & Content + +- **Course Ownership**: All courses created by the user +- **Lesson Ownership**: All lessons created by the user +- **Page Ownership**: All pages created by the user (course pages, blog pages, etc.) + +### Communities + +- **Community Ownership**: All communities created by the user +- **Community Posts**: All posts created by the user in any community +- **Community Comments**: All comments made by the user + +### Email Marketing + +- **Email Templates**: All email templates created by the user +- **Email Sequences**: All email sequences (campaigns) created by the user +- **Broadcasts**: All email broadcasts created by the user + +### Other Business Entities + +- **Payment Plans**: All payment plans created by the user +- **User Themes**: All custom themes created by the user +- **User Segments**: All user segments created by the user + +## What Gets Deleted + +The following personal data is permanently removed: + +### User Account & Profile + +- User account and profile information +- User avatar and media files +- Authentication tokens and sessions + +### Activity & Engagement + +- Course enrollments and memberships +- Lesson progress and evaluations +- Download links generated for the user +- Activity logs and analytics data +- Notifications sent to the user + +### Community Participation + +- Community membership records +- Community post subscriptions +- Community reports filed by the user + +### Email & Communications + +- Email delivery records +- Email event logs (opens, clicks, etc.) +- Ongoing email sequences for the user +- Mail request status records + +### Financial Records + +- Invoices associated with the user +- Payment subscriptions (cancelled automatically) + +### Certificates + +- Certificates issued to the user + +## GDPR Compliance + +This deletion process is designed to comply with GDPR Article 17 (Right to Erasure). When a user is deleted: + +- All personal data is permanently removed +- Business entities are preserved to maintain system integrity +- The operation is logged for audit purposes +- Payment subscriptions are automatically cancelled + +## Safety Measures + +CourseLit implements several safety measures to prevent accidental data loss: + +1. **Permission Validation**: Ensures at least one user retains each critical permission +2. **Self-Deletion Prevention**: You cannot delete your own account +3. **Confirmation Required**: Deletion requires explicit confirmation +4. **Atomic Operation**: The entire deletion process succeeds or fails as a unit + +## After Deletion + +After a user is deleted: + +- All their business entities (courses, communities, etc.) continue to function normally under the new owner +- Students enrolled in their courses can continue learning +- Community members can continue participating +- Email sequences continue running for other users +- The deleted user cannot log in anymore + +## Handling Subscription Cancellations + +If the deleted user had active payment subscriptions: + +- All subscriptions are automatically cancelled +- The payment provider (Stripe, PayPal, etc.) is notified +- No further charges will occur +- Refunds must be handled manually through your payment provider if needed + +## Best Practices + +1. **Review Before Deletion**: Check what content and entities the user owns before deleting +2. **Notify Stakeholders**: If the user created important courses or communities, inform relevant team members +3. **Export Data First**: If you need to retain any information for records, export it before deletion +4. **Handle Refunds**: Process any necessary refunds through your payment provider before deletion +5. **Document the Action**: Keep a record of why and when the user was deleted for compliance purposes + +## Troubleshooting + +### Cannot Delete User + +If you encounter an error when trying to delete a user: + +- **"Cannot delete last user with permission X"**: This user is the last one with a critical permission. Assign this permission to another user first. +- **"Action not allowed"**: You don't have the `Manage Users` permission. +- **"User not found"**: The user may have already been deleted or doesn't exist. + +### Subscription Cancellation Failed + +If a subscription fails to cancel: + +1. Note the subscription ID from the error message +2. Manually cancel the subscription in your payment provider's dashboard +3. Try the deletion again + +## Stuck somewhere? + +We are always here for you. Come chat with us in our Discord channel or send a tweet at @CourseLit. diff --git a/apps/queue/src/index.ts b/apps/queue/src/index.ts index 437e98a3d..806350bca 100644 --- a/apps/queue/src/index.ts +++ b/apps/queue/src/index.ts @@ -17,7 +17,7 @@ app.use("/job", verifyJWTMiddleware, jobRoutes); app.use("/sse", sseRoutes); app.get("/healthy", (req, res) => { - res.status(200).json({ success: true }); + res.status(200).json({ status: "ok", uptime: process.uptime() }); }); startEmailAutomation(); diff --git a/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid copy.js b/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid copy.js new file mode 100644 index 000000000..f22aa7750 --- /dev/null +++ b/apps/web/.migrations/10-11-25_00-40-migrate-lesson-creatorid-to-userid copy.js @@ -0,0 +1,96 @@ +import mongoose from "mongoose"; + +function generateUniqueId() { + return nanoid(); +} + +mongoose.connect(process.env.DB_CONNECTION_STRING, { + useNewUrlParser: true, + useUnifiedTopology: true, +}); + +const CourseSchema = new mongoose.Schema( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + courseId: { type: String, required: true, default: generateUniqueId }, + creatorId: { type: String, required: true }, + groups: [ + { + _id: { + type: String, + required: true, + default: generateUniqueId, + }, + }, + ], + }, + { + timestamps: true, + }, +); +const Course = mongoose.model("Course", CourseSchema); + +const LessonSchema = new mongoose.Schema({ + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + lessonId: { type: String, required: true, default: generateUniqueId }, + creatorId: { type: String, required: true }, + courseId: { type: String, required: true }, + groupId: { type: String, required: true }, +}); + +const Lesson = mongoose.model("Lesson", LessonSchema); + +async function migrateLessonCreatorIdToUserId() { + const courses = await Course.find({}); + for (const course of courses) { + console.log(`Updating lessons for course ${course.courseId}`); + await Lesson.updateMany( + { + domain: course.domain, + courseId: course.courseId, + }, + { + $set: { + creatorId: course.creatorId, + }, + }, + ); + console.log(`Updated lessons for course ${course.courseId}`); + } +} + +async function deleteOrphanLessons() { + const courses = await Course.find({}).lean(); + for (const course of courses) { + const groupsIds = course.groups.map((group) => group._id); + const lessons = await Lesson.find({ + domain: course.domain, + courseId: course.courseId, + }).lean(); + + const orphanLessons = lessons.filter( + (lesson) => !groupsIds.includes(lesson.groupId), + ); + + if (orphanLessons.length > 0) { + console.log( + `Detected ${orphanLessons.length} orphan lessons for course ${course.courseId}`, + ); + const query = { + _id: { + $in: orphanLessons.map((lesson) => lesson._id), + }, + }; + await Lesson.deleteMany(query); + console.log( + `Deleted ${orphanLessons.length} orphan lessons for course ${course.courseId}`, + ); + } + } +} + +(async () => { + await migrateLessonCreatorIdToUserId(); + await deleteOrphanLessons(); + mongoose.connection.close(); +})(); diff --git a/apps/web/__mocks__/medialit.ts b/apps/web/__mocks__/medialit.ts new file mode 100644 index 000000000..5bc291a7c --- /dev/null +++ b/apps/web/__mocks__/medialit.ts @@ -0,0 +1,28 @@ +class MediaLit { + endpoint: string; + constructor(config: { endpoint?: string }) { + this.endpoint = config.endpoint || "https://medialit.example.com"; + } + + async get(mediaId: string) { + return { + mediaId, + file: "mock-file", + originalFileName: "mock-file", + mimeType: "image/png", + size: 0, + access: "public", + url: `https://medialit.example.com/${mediaId}/main.png`, + }; + } + + async getSignature(_: { group: string }) { + return "mock-signature"; + } + + async delete(_: string) { + return true; + } +} + +export { MediaLit }; diff --git a/apps/web/app/(with-contexts)/(with-layout)/blog/[slug]/[id]/layout.tsx b/apps/web/app/(with-contexts)/(with-layout)/blog/[slug]/[id]/layout.tsx index c55593b72..baac6c145 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/blog/[slug]/[id]/layout.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/blog/[slug]/[id]/layout.tsx @@ -64,7 +64,6 @@ async function getProduct(id: string, address: string): Promise { thumbnail file } - creatorName updatedAt } } diff --git a/apps/web/app/(with-contexts)/action.ts b/apps/web/app/(with-contexts)/action.ts index 22a76b6b7..b1bee9b8f 100644 --- a/apps/web/app/(with-contexts)/action.ts +++ b/apps/web/app/(with-contexts)/action.ts @@ -14,31 +14,34 @@ export async function getProfile(): Promise { const userId = (session?.user as any)?.userId; const domainId = (session?.user as any)?.domain; - const user = await getUser(userId, { - user: { - userId, - }, - subdomain: { - _id: domainId, - }, - } as GQLContext); + try { + const user = await getUser(userId, { + user: { + userId, + }, + subdomain: { + _id: domainId, + }, + } as GQLContext); - if (!isSelf(user)) { + if (!isSelf(user)) { + return null; + } + + return { + name: user.name || "", + fetched: true, + purchases: user.purchases, + email: user.email, + bio: user.bio, + permissions: user.permissions, + userId: user.userId, + subscribedToUpdates: user.subscribedToUpdates, + avatar: user.avatar, + }; + } catch (error) { return null; } - - return { - name: user.name || "", - id: user._id.toString(), - fetched: true, - purchases: user.purchases, - email: user.email, - bio: user.bio, - permissions: user.permissions, - userId: user.userId, - subscribedToUpdates: user.subscribedToUpdates, - avatar: user.avatar, - }; } function isSelf(user: any): user is User & { _id: Types.ObjectId } { diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts index fbf1fe86a..ea139709d 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts @@ -14,7 +14,6 @@ type CourseWithoutGroups = Pick< | "description" | "featuredImage" | "updatedAt" - | "creatorName" | "creatorId" | "slug" | "cost" @@ -42,7 +41,6 @@ export const getProduct = async ( caption }, updatedAt, - creatorName, creatorId, slug, cost, @@ -109,7 +107,6 @@ export function formatCourse( description: post.description, featuredImage: post.featuredImage, updatedAt: post.updatedAt, - creatorName: post.creatorName, creatorId: post.creatorId, slug: post.slug, cost: post.cost, diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/blog/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/blog/[id]/page.tsx index b67903da7..d2a7da168 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/blog/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/blog/[id]/page.tsx @@ -17,6 +17,7 @@ import { } from "@courselit/components-library"; import { MoreVert } from "@courselit/icons"; import { + APP_MESSAGE_COURSE_DELETED, DELETE_PRODUCT_POPUP_HEADER, DELETE_PRODUCT_POPUP_TEXT, EDIT_BLOG, @@ -24,6 +25,7 @@ import { MENU_BLOG_VISIT, PAGE_TITLE_404, PRODUCT_TABLE_CONTEXT_MENU_DELETE_PRODUCT, + TOAST_TITLE_SUCCESS, } from "@ui-config/strings"; import { truncate } from "@ui-lib/utils"; import { useRouter, useSearchParams } from "next/navigation"; @@ -40,7 +42,7 @@ export default function Page(props: { params: Promise<{ id: string }> }) { const searchParams = useSearchParams(); const [tab, setTab] = useState(searchParams?.get("tab") || "Details"); const address = useContext(AddressContext); - const course = useCourse(id, address); + const course = useCourse(id); const router = useRouter(); const { toast } = useToast(); @@ -90,6 +92,11 @@ export default function Page(props: { params: Promise<{ id: string }> }) { router.replace( `/dashboard/blogs`, ); + toast({ + title: TOAST_TITLE_SUCCESS, + description: + APP_MESSAGE_COURSE_DELETED, + }); }, toast, }) diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/page.tsx index cdeebf826..7651f3107 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/page.tsx @@ -41,6 +41,7 @@ import { PRODUCT_EMPTY_WARNING, PRODUCT_TABLE_CONTEXT_MENU_INVITE_A_CUSTOMER, PRODUCT_UNPUBLISHED_WARNING, + MANAGE_LINK_TEXT, TOAST_TITLE_SUCCESS, VIEW_PAGE_MENU_ITEM, } from "@ui-config/strings"; @@ -112,7 +113,7 @@ export default function DashboardPage() { href={`/dashboard/product/${productId}/manage#publish`} className="underline" > - Manage + {MANAGE_LINK_TEXT} )} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/profile/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/profile/page.tsx index 1fc4bd258..2e033ab58 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/profile/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/profile/page.tsx @@ -7,20 +7,24 @@ import { Avatar, AvatarFallback, AvatarImage, - Button2, Checkbox, - Form, - FormField, Image, MediaSelector, - PageBuilderPropertyHeader, - Section, useToast, } from "@courselit/components-library"; +import { + Field, + FieldContent, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@components/ui/field"; import { FetchBuilder } from "@courselit/utils"; import { MIMETYPE_IMAGE } from "@ui-config/constants"; import { BUTTON_SAVE, + BUTTON_SAVING, TOAST_TITLE_ERROR, MEDIA_SELECTOR_REMOVE_BTN_CAPTION, MEDIA_SELECTOR_UPLOAD_BTN_CAPTION, @@ -33,17 +37,26 @@ import { PROFILE_SECTION_DETAILS_NAME, PROFILE_SECTION_DISPLAY_PICTURE, } from "@ui-config/strings"; -import { FormEvent, useContext, useEffect, useState } from "react"; +import { FormEvent, useContext, useEffect, useRef, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card"; +import { Input } from "@components/ui/input"; +import { Textarea } from "@components/ui/textarea"; +import { Button } from "@components/ui/button"; const breadcrumbs = [{ label: PROFILE_PAGE_HEADER, href: "#" }]; export default function Page() { const [bio, setBio] = useState(""); const [name, setName] = useState(""); - const [user, setUser] = - useState>(); + // const [user, setUser] = + // useState>(); const [avatar, setAvatar] = useState>({}); const [subscribedToUpdates, setSubscribedToUpdates] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const initialDetailsRef = useRef<{ name: string; bio: string }>({ + name: "", + bio: "", + }); const { toast } = useToast(); const { profile, setProfile } = useContext(ProfileContext); @@ -80,11 +93,15 @@ export default function Page() { try { const response = await fetch.exec(); if (response.user) { - setUser(response.user); + // setUser(response.user); setName(response.user.name); setBio(response.user.bio); setAvatar(response.user.avatar); setSubscribedToUpdates(response.user.subscribedToUpdates); + initialDetailsRef.current = { + name: response.user.name ?? "", + bio: response.user.bio ?? "", + }; } } catch (err: any) { toast({ @@ -124,7 +141,7 @@ export default function Page() { .setPayload({ query: mutation, variables: { - id: profile!.id, + id: profile!.userId, avatar: media || null, }, }) @@ -151,6 +168,7 @@ export default function Page() { const saveDetails = async (e: FormEvent) => { e.preventDefault(); + setIsSaving(true); const mutation = ` mutation ($id: ID!, $name: String, $bio: String) { user: updateUser(userData: { @@ -188,7 +206,7 @@ export default function Page() { .setPayload({ query: mutation, variables: { - id: profile!.id, + id: profile!.userId, name, bio, }, @@ -200,6 +218,10 @@ export default function Page() { const response = await fetch.exec(); if (response.user) { setProfile(response.user); + initialDetailsRef.current = { + name, + bio, + }; } } catch (err: any) { toast({ @@ -207,6 +229,8 @@ export default function Page() { description: err.message, variant: "destructive", }); + } finally { + setIsSaving(false); } }; @@ -227,7 +251,7 @@ export default function Page() { .setPayload({ query: mutation, variables: { - id: profile.id, + id: profile!.userId, subscribedToUpdates: state, }, }) @@ -246,100 +270,152 @@ export default function Page() { } }; + const isSaveDisabled = + name === initialDetailsRef.current.name && + bio === initialDetailsRef.current.bio; + return (

{PROFILE_PAGE_HEADER}

-
-
- - - - - profile pic - - - { - if (media) { - updateProfilePic(media); - } - }} - onRemove={() => { - updateProfilePic(); - }} - access="public" - strings={{ - buttonCaption: MEDIA_SELECTOR_UPLOAD_BTN_CAPTION, - removeButtonCaption: - MEDIA_SELECTOR_REMOVE_BTN_CAPTION, - }} - type="user" - hidePreview={true} - mimeTypesToShow={MIMETYPE_IMAGE} - /> -
-
-
- {}} - disabled={true} - /> - setName(event.target.value)} - /> - setBio(event.target.value)} - label={PROFILE_SECTION_DETAILS_BIO} - /> -
- + + + {PROFILE_SECTION_DISPLAY_PICTURE} + + + + + + profile pic + + + { + if (media) { + updateProfilePic(media); } - > - {BUTTON_SAVE} - -
-
-
+ }} + onRemove={() => { + updateProfilePic(); + }} + access="public" + strings={{ + buttonCaption: + MEDIA_SELECTOR_UPLOAD_BTN_CAPTION, + removeButtonCaption: + MEDIA_SELECTOR_REMOVE_BTN_CAPTION, + }} + type="user" + hidePreview={true} + mimeTypesToShow={MIMETYPE_IMAGE} + /> + + + +
+ + {PROFILE_SECTION_DETAILS} + + +
+ + + + {PROFILE_SECTION_DETAILS_EMAIL} + + + + + + {PROFILE_SECTION_DETAILS_NAME} + + + setName(event.target.value) + } + required + /> + + + + {PROFILE_SECTION_DETAILS_BIO} + +