# Settings

Account settings modal over the workspace. Opened from the gear, account flyout **Settings** / **Get help**, or `/dashboard?settings={tab}` (also recognized on `/my-courses?settings=`). `buildSettingsHref` always points at `/dashboard?settings=…`. After open, the query is stripped. Legacy `/settings`, `/settings/profile`, `/settings/security`, `/settings/notifications`, `/settings/billing`, `/settings/help`, `/settings/danger` redirect here. `/settings/appearance` and `/settings/language` redirect to profile.

PAGE: /dashboard?settings={tab}
Purpose: Every student account setting (identity, notifications, billing, security, delete, help).
Access: logged-in + onboarded workspace. `GET /api/workspace/settings` returns 401 if anonymous.
Entry points: Sidebar/header gear; flyout **Settings**; flyout **Get help** (help tab); security **Manage account deletion →**; marketing header gear → `/dashboard?settings=profile`.

SECTION: Modal chrome
  Copy: Dialog title (sr-only) `Settings`. Nav aria-label `Settings sections`. Load skeleton aria-label `Loading settings`. Error `Could not load settings. Please try again.` Billing placeholder `Loading settings...`
  Data shown: `GET /api/workspace/settings` → `{ settings, billing }`

  ELEMENT: Profile
    Action: toggle
    Destination: profile pane (`settings=profile`)
    Writes to: none
    Reads from: none

  ELEMENT: Notifications
    Action: toggle
    Destination: notifications pane
    Writes to: none
    Reads from: none

  ELEMENT: Plan & billing
    Action: toggle
    Destination: billing pane
    Writes to: none
    Reads from: none

  ELEMENT: Security
    Action: toggle
    Destination: security pane
    Writes to: none
    Reads from: none

  ELEMENT: Danger zone
    Action: toggle
    Destination: danger pane
    Writes to: none
    Reads from: none

  ELEMENT: Help & contact
    Action: toggle
    Destination: help pane
    Writes to: none
    Reads from: none

---

SECTION: Profile
  Copy: `Profile` `Correct name, university, and optional programs here. Paid access stays on the courses you bought — not these labels.`
  Data shown: `profiles.*`, `user_badges.*`

### Where you study
  Copy: `Where you study` `University matches AUB or LAU course offerings. Changing it does not change courses you already bought.`
  Copy: `Which university?` `AUB and LAU use different course codes. This picks the matching offering.`
  Campus (optional, LAU only): `Campus (optional)` `Beirut or Byblos does not change which Gradeful courses you can open.`

  ELEMENT: AUB / LAU
    Action: toggle
    Destination: none
    Writes to: `profiles.university` on Save changes
    Reads from: `profiles.university`

  ELEMENT: Beirut / Byblos (optional)
    Action: toggle
    Destination: none
    Writes to: `profiles.campus` on Save changes
    Reads from: `profiles.campus`

### Programs (optional)
  Copy: `Programs (optional)` `Major and minors are for later recommendations. They are not required to open a course.`
  ELEMENT: Major / Second major / Minors (typeaheads, not required)
    Writes to: `profiles.major`, `second_major`, `minors` on Save changes

### You
  Copy: `You` `How Gradeful greets you in the workspace.` Labels `Profile photo` `Full name` `Nickname` `{n}/20` `Used in your workspace greeting. Letters, numbers, and underscores only.` `Account email` `This is your Gradeful login and receipts. It is not overwritten by an AUB or LAU address.` `Institution email` `Optional, and separate from login. Checkout may ask for your AUB or LAU address as a payment contact.`
  Data shown: `profiles.full_name`, `nickname`, `avatar_url`; Auth email

  ELEMENT: Change photo
    Action: open modal
    Destination: crop dialog `Crop your photo` `Drag to reposition and use the slider to zoom. We save a square crop.`
    Writes to: none until Save photo
    Reads from: `profiles.avatar_url`

  ELEMENT: Zoom (range 1–3)
    Action: toggle
    Destination: none
    Writes to: none
    Reads from: none

  ELEMENT: Cancel
    Action: toggle
    Destination: closes crop
    Writes to: none
    Reads from: none

  ELEMENT: Save photo / Uploading...
    Action: submit
    Destination: none
    Writes to: Storage `avatars/{userId}/avatar.jpg`; `profiles.avatar_url`; `profiles.updated_at`
    Reads from: cropped blob
    Success: `Profile photo updated`
    Errors: `Upload a JPG, PNG, or WebP image.` `Photo must be 5MB or smaller.` `You must be signed in to change your photo.` `Choose a photo to upload.` `Your photo could not be uploaded. Please try again.` `Your photo uploaded but could not be saved. Please try again.`

  ELEMENT: Full name
    Action: toggle
    Destination: none
    Writes to: `profiles.full_name` on Save changes (max 100)
    Reads from: `profiles.full_name`

  ELEMENT: Nickname
    Action: toggle
    Destination: none
    Writes to: `profiles.nickname` (2–20, `[a-zA-Z0-9_]+`)
    Reads from: `profiles.nickname`
    Errors: `Enter a nickname.` `Use at least 2 characters.` `Use at most 20 characters.` `Use letters, numbers, and underscores only.`

  ELEMENT: Email (disabled)
    Action: none
    Destination: none
    Writes to: none
    Reads from: `auth.users.email`

  ELEMENT: Contact support
    Action: external link
    Destination: `https://wa.me/96179057533`
    Writes to: none
    Reads from: none

  ELEMENT: Save changes / Saving...
    Action: submit
    Destination: toast + refresh
    Writes to: `profiles.full_name`, `profiles.nickname`, `profiles.updated_at`
    Reads from: form
    Success: `Profile updated`
    Error: `Something went wrong`

### Showing up
  Copy: `Showing up` `Quiet milestones for coming back. Not trophies for never missing a day.`
  Cards: `One week` `Showed up seven days in a row`; `Two weeks` `A fortnight of showing up`; `One month` `A month of making this a habit`; `Two months` `Sixty days of quiet consistency`; `One hundred days` `A home you kept returning to`. Earned subtitle formatted `MMM D, YYYY`.
  Data shown: `user_badges.badge_type`, `user_badges.earned_at`

  ELEMENT: none (display only)
    Action: none
    Destination: none
    Writes to: none
    Reads from: `user_badges`

---

SECTION: Notifications
  Copy: `Notifications` `Choose what you hear about from us. Study reminders stay quiet and optional.`
  Data shown: `profiles.notification_preferences` JSON. Defaults: `access_granted` true, `new_course` true, `announcements` false, `study_reminders` true. Key `reservation_updates` exists in JSON (default true) with **no toggle**.

  ELEMENT: Access granted emails
    Action: toggle
    Destination: none (saves immediately)
    Writes to: `profiles.notification_preferences.access_granted`, `profiles.updated_at`
    Reads from: same
    Description: `Get an email when your course access is activated.`

  ELEMENT: New course available
    Action: toggle
    Destination: none
    Writes to: `profiles.notification_preferences.new_course`
    Reads from: same
    Description: `Hear when a new course pack is added for your university.`

  ELEMENT: Platform announcements
    Action: toggle
    Destination: none
    Writes to: `profiles.notification_preferences.announcements`
    Reads from: same
    Description: `Occasional updates about Gradeful features and changes.`

  ELEMENT: Study reminders
    Action: toggle
    Destination: none
    Writes to: `profiles.notification_preferences.study_reminders`
    Reads from: same
    Description: `A gentle evening nudge if you haven't opened a pack yet. Never a guilt trip.`
    Toasts: `Preferences updated` `Something went wrong`

---

SECTION: Plan & billing
  Copy: `Plans & access` `Your active courses and payment history. Change plan anytime.` `Active access` `Payment history` columns `Date` `Plan` `Amount` `Status`
  Data shown: Active cards from `user_access` (`plan_type`, `course_ids`, `expires_at`, statuses paid/confirmed/pending, not expired) joined to `courses.code`. History from Whish `orders` for the signed-in user (`plan`, `amount`, `payment_status`, `created_at`). Plan names Focus / Balance / Full Load / Crunch else `Course access`. Status from `commercialOrderStatus()`. `Courses covered: {codes | Selected courses | No courses selected}` `Expires {date | Not set}` badge `Expiring soon` if ≤14 days. Empty access `No active access yet`. Empty history `No payment history yet`. Amount uses `orders.amount`.

  ELEMENT: Upgrade or change plan
    Action: navigate
    Destination: `/pricing`
    Writes to: none
    Reads from: none

  ELEMENT: Browse plans →
    Action: navigate
    Destination: `/pricing`
    Writes to: none
    Reads from: visible when no active access

---

SECTION: Security
  Copy: `Security` `Manage how you sign in to Gradeful` loading `Loading sign-in methods…`
  Data shown: Supabase Auth identities and session (`last_sign_in_at` / `updated_at` as last active)

### Change password (email identity only)
  Copy: `Change password` labels `Current password` `New password` `Confirm new password`

  ELEMENT: Update password / Updating...
    Action: submit
    Destination: none
    Writes to: `auth.users` password (`signInWithPassword` then `updateUser`)
    Reads from: email prop + form
    Toasts: `New password must be at least 8 characters` `New passwords do not match` `Current password is incorrect` `Something went wrong` `Password updated`

### Phone sign-in (phone identity)
  Copy: `Phone sign-in` `You sign in with your phone number ending in ••••{lastFour}.`
  Data shown: Auth phone identity
  ELEMENT: none (display)

### Google
  Copy: `Google` `You sign in with Google.` status `Connected` / `Not connected` check aria-label `Connected`

  ELEMENT: Connect / Working...
    Action: submit
    Destination: `/auth/callback?next=/dashboard?settings=security`
    Writes to: Auth identity (`linkIdentity` Google)
    Reads from: identities
    Toast: `Google could not be connected`

  ELEMENT: Disconnect / Working...
    Action: submit
    Destination: none
    Writes to: Auth (`unlinkIdentity`)
    Reads from: blocked if only one identity
    Toasts: `Google could not be disconnected` `Add another sign-in method before disconnecting Google` `Google disconnected`

### Add password backup (Google-only)
  Copy: `Add password backup` `Add a password so you can still sign in if Google is unavailable.`
  ELEMENT: Password / Confirm password / Add backup / Saving...
    Action: submit
    Destination: none
    Writes to: Auth `updateUser({ password })`
    Reads from: none
    Toasts: `Password must be at least 8 characters` `Passwords do not match` `We could not add a password backup. Try again.` `Password backup added`

### Add email and password backup (phone-only)
  Copy: `Add email and password backup` `Add an email and password so you have another way back in.` After send: `We sent a confirmation link to {email}. Confirm it to finish adding your backup sign-in method.`
  ELEMENT: Email / Password / Confirm password / Add backup / Saving...
    Action: submit
    Destination: none
    Writes to: Auth email+password
    Reads from: none
    Toasts: `Enter a valid email address.` `We could not add email and password backup. Try again.` `Check your email to confirm the backup sign-in method.` plus password match/length toasts above

### This device
  Copy: `This device` `Last active {Just now | Recently | formatted datetime}`
  ELEMENT: none (display)
    Reads from: Auth `last_sign_in_at` / `updated_at`

  ELEMENT: Manage account deletion →
    Action: toggle
    Destination: Danger zone tab (fallback `/dashboard?settings=danger`)
    Writes to: none
    Reads from: none

---

SECTION: Danger zone
  Copy: `Danger zone` `Account actions that cannot be undone`

### Sign out
  Copy: `Sign out` `End your session on this device.`

  ELEMENT: Sign out / Signing out...
    Action: submit
    Destination: `/`
    Writes to: clears Auth session
    Reads from: none

### Delete your account
  Copy: `Delete your account` `This permanently removes your profile, access records, and account data. This cannot be undone.` Dialog `Delete your account?` `This action is permanent. Type DELETE below to confirm.` Label `Confirmation` placeholder `DELETE`

  ELEMENT: Delete account
    Action: open modal
    Destination: confirm dialog
    Writes to: none
    Reads from: none

  ELEMENT: Cancel
    Action: toggle
    Destination: closes dialog
    Writes to: none
    Reads from: none

  ELEMENT: Permanently delete / Deleting...
    Action: submit
    Destination: `/` on success; `/login` if unauthorized
    Writes to: deletes Auth user (`auth.admin.deleteUser`); related rows cascade
    Reads from: confirmation must equal `DELETE`
    Errors: `You must be signed in to delete your account.` `We couldn't delete your account right now. Please contact support.`

---

SECTION: Help & contact
  Copy: `Help & contact` `Reach out or browse common answers` `Chat with us` `Usually responds within a day` `Request a missing course` `We prioritize by demand`

  ELEMENT: Open WhatsApp
    Action: external link
    Destination: `https://wa.me/96179057533`
    Writes to: none
    Reads from: none

  ELEMENT: Request a course
    Action: navigate
    Destination: `/courses#course-request`
    Writes to: none
    Reads from: none

  ELEMENT: Privacy Policy
    Action: navigate
    Destination: `/privacy`
    Writes to: none
    Reads from: none

  ELEMENT: Terms of Service
    Action: navigate
    Destination: `/terms`
    Writes to: none
    Reads from: none

  ELEMENT: Refund Policy
    Action: navigate
    Destination: `/refunds`
    Writes to: none
    Reads from: none

  ELEMENT: Academic Integrity
    Action: navigate
    Destination: `/academic-integrity`
    Writes to: none
    Reads from: none

---

## Not in the modal (removed half-features)

Dark mode and extra languages are not shipped. The orphaned Appearance and Language panes were removed so settings only shows working tabs. `/settings/appearance` and `/settings/language` still redirect to profile. `profiles.theme_preference` and `profiles.language_preference` remain in the schema for a later full feature — do not expose “coming soon” UI until then.
