onasty/web/src/Pages/Profile.elm (view raw)
Olexandr Smirnov
Olexandr Smirnov
ss2316544@gmail.com feat(web): add account settings (#190)..., 9 months ago
ss2316544@gmail.com feat(web): add account settings (#190)..., 9 months ago
| 1 | module Pages.Profile exposing (Model, Msg, ViewVariant, page) |
| 2 | |
| 3 | import Api |
| 4 | import Api.Profile |
| 5 | import Auth |
| 6 | import Components.Box |
| 7 | import Components.Form |
| 8 | import Components.Utils |
| 9 | import Data.Me exposing (Me) |
| 10 | import Effect exposing (Effect) |
| 11 | import Html as H exposing (Html) |
| 12 | import Html.Attributes as A |
| 13 | import Html.Events |
| 14 | import Layouts |
| 15 | import Page exposing (Page) |
| 16 | import Route exposing (Route) |
| 17 | import Shared |
| 18 | import Time.Format |
| 19 | import Validators |
| 20 | import View exposing (View) |
| 21 | |
| 22 | |
| 23 | page : Auth.User -> Shared.Model -> Route () -> Page Model Msg |
| 24 | page _ shared _ = |
| 25 | Page.new |
| 26 | { init = init shared |
| 27 | , update = update |
| 28 | , subscriptions = subscriptions |
| 29 | , view = view shared |
| 30 | } |
| 31 | |> Page.withLayout (\_ -> Layouts.Header {}) |
| 32 | |
| 33 | |
| 34 | |
| 35 | -- INIT |
| 36 | |
| 37 | |
| 38 | type alias Model = |
| 39 | { view : ViewVariant |
| 40 | , me : Api.Response Me |
| 41 | , password : { current : String, new : String, confirm : String } |
| 42 | , email : String |
| 43 | , apiError : Maybe Api.Error |
| 44 | , isFormSentSuccessfully : Bool |
| 45 | } |
| 46 | |
| 47 | |
| 48 | init : Shared.Model -> () -> ( Model, Effect Msg ) |
| 49 | init _ () = |
| 50 | ( { view = Overview |
| 51 | , me = Api.Loading |
| 52 | , password = { current = "", new = "", confirm = "" } |
| 53 | , email = "" |
| 54 | , apiError = Nothing |
| 55 | , isFormSentSuccessfully = False |
| 56 | } |
| 57 | , Api.Profile.me { onResponse = ApiMeResponded } |
| 58 | ) |
| 59 | |
| 60 | |
| 61 | |
| 62 | -- UPDATE |
| 63 | |
| 64 | |
| 65 | type ViewVariant |
| 66 | = Overview |
| 67 | | Password |
| 68 | |
| 69 | |
| 70 | |
| 71 | type Field |
| 72 | = PasswordCurrent |
| 73 | | PasswordNew |
| 74 | | PasswordConfirm |
| 75 | | EmailNew |
| 76 | |
| 77 | |
| 78 | type Msg |
| 79 | = UserChangedView ViewVariant |
| 80 | | UserClickedSubmit |
| 81 | | UserChangedField Field String |
| 82 | | ApiMeResponded (Result Api.Error Me) |
| 83 | | ApiChangePasswordResponsed (Result Api.Error ()) |
| 84 | | ApiRequestEmailChangeResponsed (Result Api.Error ()) |
| 85 | |
| 86 | |
| 87 | update : Msg -> Model -> ( Model, Effect Msg ) |
| 88 | update msg model = |
| 89 | case msg of |
| 90 | UserChangedView variant -> |
| 91 | ( { model | view = variant, isFormSentSuccessfully = False, apiError = Nothing }, Effect.none ) |
| 92 | |
| 93 | UserChangedField PasswordCurrent value -> |
| 94 | ( { model | password = { current = value, new = model.password.new, confirm = model.password.confirm } }, Effect.none ) |
| 95 | |
| 96 | UserChangedField PasswordNew value -> |
| 97 | ( { model | password = { current = model.password.current, new = value, confirm = model.password.confirm } }, Effect.none ) |
| 98 | |
| 99 | UserChangedField PasswordConfirm value -> |
| 100 | ( { model | password = { current = model.password.current, new = model.password.new, confirm = value } }, Effect.none ) |
| 101 | |
| 102 | UserChangedField EmailNew value -> |
| 103 | ( { model | email = value }, Effect.none ) |
| 104 | |
| 105 | UserClickedSubmit -> |
| 106 | case model.view of |
| 107 | Password -> |
| 108 | ( model |
| 109 | , Api.Profile.changePassword |
| 110 | { onResponse = ApiChangePasswordResponsed |
| 111 | , currentPassword = model.password.current |
| 112 | , newPassword = model.password.new |
| 113 | } |
| 114 | ) |
| 115 | |
| 116 | Email -> |
| 117 | ( model |
| 118 | , Api.Profile.requestEmailChange |
| 119 | { onResponse = ApiRequestEmailChangeResponsed |
| 120 | , newEmail = model.email |
| 121 | } |
| 122 | ) |
| 123 | |
| 124 | _ -> |
| 125 | ( model, Effect.none ) |
| 126 | |
| 127 | ApiMeResponded (Ok userData) -> |
| 128 | ( { model | me = Api.Success userData }, Effect.none ) |
| 129 | |
| 130 | ApiMeResponded (Err error) -> |
| 131 | ( { model | me = Api.Failure error }, Effect.none ) |
| 132 | |
| 133 | ApiChangePasswordResponsed (Ok ()) -> |
| 134 | ( { model | isFormSentSuccessfully = True }, Effect.none ) |
| 135 | |
| 136 | ApiChangePasswordResponsed (Err err) -> |
| 137 | ( { model | apiError = Just err }, Effect.none ) |
| 138 | |
| 139 | ApiRequestEmailChangeResponsed (Ok ()) -> |
| 140 | ( { model | isFormSentSuccessfully = True }, Effect.none ) |
| 141 | |
| 142 | ApiRequestEmailChangeResponsed (Err err) -> |
| 143 | ( { model | apiError = Just err }, Effect.none ) |
| 144 | |
| 145 | |
| 146 | subscriptions : Model -> Sub Msg |
| 147 | subscriptions _ = |
| 148 | Sub.none |
| 149 | |
| 150 | |
| 151 | |
| 152 | -- VIEW |
| 153 | |
| 154 | |
| 155 | view : Shared.Model -> Model -> View Msg |
| 156 | view shared model = |
| 157 | { title = "Profile" |
| 158 | , body = |
| 159 | [ H.div [ A.class "w-full p-6 max-w-4xl mx-auto" ] |
| 160 | [ H.div [ A.class "rounded-lg border border-gray-200 shadow-sm" ] |
| 161 | [ H.div [ A.class "p-6 border-b border-gray-200" ] |
| 162 | [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e)) |
| 163 | , H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text "Account Settings" ] |
| 164 | , H.p [ A.class "text-gray-600" ] [ H.text "Manage your account preferences and security settings" ] |
| 165 | ] |
| 166 | , H.div [ A.class "flex" ] |
| 167 | [ viewNavigationSidebar model |
| 168 | , H.div [ A.class "flex-1 p-6" ] |
| 169 | [ case model.me of |
| 170 | Api.Success me -> |
| 171 | case model.view of |
| 172 | Overview -> |
| 173 | viewOverview shared me |
| 174 | |
| 175 | Password -> |
| 176 | viewPassword model.password (isFormDisabled model) model.isFormSentSuccessfully |
| 177 | |
| 178 | Email -> |
| 179 | viewEmail me model.email (isFormDisabled model) model.isFormSentSuccessfully |
| 180 | |
| 181 | Api.Loading -> |
| 182 | H.text "Loading..." |
| 183 | |
| 184 | Api.Failure err -> |
| 185 | H.text ("ERROR: " ++ Api.errorMessage err) |
| 186 | ] |
| 187 | ] |
| 188 | ] |
| 189 | ] |
| 190 | ] |
| 191 | } |
| 192 | |
| 193 | |
| 194 | isFormDisabled : Model -> Bool |
| 195 | isFormDisabled model = |
| 196 | case model.view of |
| 197 | Overview -> |
| 198 | True |
| 199 | |
| 200 | Password -> |
| 201 | (Validators.password model.password.new /= Nothing) |
| 202 | || (Validators.passwords model.password.new model.password.confirm /= Nothing) |
| 203 | |
| 204 | Email -> |
| 205 | Validators.email model.email /= Nothing |
| 206 | |
| 207 | |
| 208 | viewNavigationSidebar : Model -> Html Msg |
| 209 | viewNavigationSidebar model = |
| 210 | let |
| 211 | button variant text = |
| 212 | Components.Form.button |
| 213 | { text = text |
| 214 | , onClick = UserChangedView variant |
| 215 | , disabled = model.view == variant |
| 216 | , style = Components.Form.PrimaryReverse (model.view == variant) |
| 217 | } |
| 218 | in |
| 219 | H.div [ A.class "w-64 border-r border-gray-200 p-6" ] |
| 220 | [ H.nav [ A.class "[&>*]:w-full space-y-2" ] |
| 221 | [ button Overview "Overview" |
| 222 | , button Password "Password" |
| 223 | , button Email "Email" |
| 224 | ] |
| 225 | ] |
| 226 | |
| 227 | |
| 228 | viewOverview : Shared.Model -> Me -> Html Msg |
| 229 | viewOverview shared me = |
| 230 | let |
| 231 | infoBox title text = |
| 232 | H.div [ A.class "bg-gray-50 rounded-lg p-4" ] |
| 233 | [ H.div [ A.class "flex items-center gap-3 mb-2" ] |
| 234 | [ H.h3 [ A.class "font-medium text-gray-900" ] [ H.text title ] ] |
| 235 | , H.p [ A.class "text-gray-700" ] [ H.text text ] |
| 236 | ] |
| 237 | in |
| 238 | viewWrapper |
| 239 | { title = "Account Overview" |
| 240 | , body = |
| 241 | H.div [ A.class "grid grid-cols-1 md:grid-cols-2 gap-6" ] |
| 242 | [ infoBox "Email Address" me.email |
| 243 | , infoBox "Member Since" (Time.Format.toString shared.timeZone me.createdAt) |
| 244 | , infoBox "Last Login" (Time.Format.toString shared.timeZone me.lastLoginAt) |
| 245 | , infoBox "Total Notes Created" (String.fromInt me.notesCreated) |
| 246 | ] |
| 247 | } |
| 248 | |
| 249 | |
| 250 | viewPassword : { current : String, new : String, confirm : String } -> Bool -> Bool -> Html Msg |
| 251 | viewPassword password isButtonDisabled isFormSentSuccessfully = |
| 252 | let |
| 253 | input : { label : String, field : Field, value : String, error : Maybe String } -> Html Msg |
| 254 | input { label, field, value, error } = |
| 255 | Components.Form.input |
| 256 | { label = label |
| 257 | , id = label |
| 258 | , field = field |
| 259 | , onInput = UserChangedField field |
| 260 | , placeholder = "" |
| 261 | , value = value |
| 262 | , required = True |
| 263 | , type_ = "password" |
| 264 | , style = Components.Form.Simple |
| 265 | , error = error |
| 266 | } |
| 267 | in |
| 268 | viewWrapper |
| 269 | { title = "Change Password" |
| 270 | , body = |
| 271 | H.form |
| 272 | [ A.class "space-y-4 max-w-md" |
| 273 | , Html.Events.onSubmit UserClickedSubmit |
| 274 | ] |
| 275 | [ Components.Utils.viewIf isFormSentSuccessfully (Components.Box.successText "Password updated successfully!") |
| 276 | , input { label = "Current Password", field = PasswordCurrent, value = password.current, error = Nothing } |
| 277 | , input { label = "New Password", field = PasswordNew, value = password.new, error = Validators.password password.new } |
| 278 | , input { label = "Confirm New Password", field = PasswordConfirm, value = password.confirm, error = Validators.passwords password.new password.confirm } |
| 279 | , Components.Form.submitButton |
| 280 | { disabled = isButtonDisabled |
| 281 | , text = "Change Password" |
| 282 | , style = Components.Form.Primary isButtonDisabled |
| 283 | , class = "" |
| 284 | } |
| 285 | ] |
| 286 | } |
| 287 | |
| 288 | |
| 289 | viewEmail : Me -> String -> Bool -> Bool -> Html Msg |
| 290 | viewEmail me email isButtonDisabled isFormSentSuccessfully = |
| 291 | viewWrapper |
| 292 | { title = "Change Email Address" |
| 293 | , body = |
| 294 | H.form |
| 295 | [ A.class "space-y-4 max-w-md" |
| 296 | , Html.Events.onSubmit UserClickedSubmit |
| 297 | ] |
| 298 | [ H.div [ A.class "mb-6 p-4 bg-blue-50 border border-blue-200 rounded-md" ] |
| 299 | [ H.h3 [ A.class "font-medium mb-1" ] [ H.text "Note:" ] |
| 300 | , H.p [] [ H.text "A confirmation email will be sent to your current email address. You must confirm the change by clicking the link in that email." ] |
| 301 | , H.p [ A.class "mt-2 text-blue-800 text-sm" ] |
| 302 | [ H.span [ A.class "font-medium" ] [ H.text ("Current email: " ++ me.email) ] |
| 303 | ] |
| 304 | ] |
| 305 | , Components.Utils.viewIf isFormSentSuccessfully (Components.Box.successText "Email updated successfully! Please check your new email for verification.") |
| 306 | , Components.Form.input |
| 307 | { style = Components.Form.Simple |
| 308 | , id = "new-email" |
| 309 | , type_ = "email" |
| 310 | , field = EmailNew |
| 311 | , label = "New Email Address" |
| 312 | , value = email |
| 313 | , placeholder = "Enter your new email address" |
| 314 | , onInput = UserChangedField EmailNew |
| 315 | , error = Validators.email email |
| 316 | , required = True |
| 317 | } |
| 318 | , Components.Form.submitButton |
| 319 | { disabled = isButtonDisabled |
| 320 | , text = "Update Email" |
| 321 | , style = Components.Form.Primary isButtonDisabled |
| 322 | , class = "" |
| 323 | } |
| 324 | ] |
| 325 | } |
| 326 | |
| 327 | |
| 328 | viewWrapper : { title : String, body : Html Msg } -> Html Msg |
| 329 | viewWrapper { title, body } = |
| 330 | H.div [ A.class "space-y-6" ] |
| 331 | [ H.div [] |
| 332 | [ H.h2 [ A.class "text-lg font-semibold text-gray-900 mb-4" ] [ H.text title ] |
| 333 | , body |
| 334 | ] |
| 335 | ] |