cyber-elx 1.1.11 → 1.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEV_DOC/README.md +3 -0
- package/DEV_DOC/StudentProfileDev.md +835 -0
- package/package.json +1 -1
package/DEV_DOC/README.md
CHANGED
|
@@ -25,6 +25,9 @@ Custom Student List Courses page (course catalog). Displays categories grid, fea
|
|
|
25
25
|
### [StudentMyCoursesPageDev.md](StudentMyCoursesPageDev.md)
|
|
26
26
|
Custom Student My Courses page (student dashboard home). Shows statistics cards (total courses, completed, certificates), course list with progress bars, and certificate generation for completed courses. Documents the cardsInfos structure, courses array with progress tracking, and available translation keys.
|
|
27
27
|
|
|
28
|
+
### [StudentProfileDev.md](StudentProfileDev.md)
|
|
29
|
+
Custom Student Profile page for viewing and editing user information. Features view/edit mode toggle, profile image upload via CLoader component, name fields, grade selection (if enabled), and password change with real-time validation. Handles API calls to `/api/users/update_my_profile` and updates VueX store. Documents the `additionalFields` object for extensible user data.
|
|
30
|
+
|
|
28
31
|
### [StudentStartupDev.md](StudentStartupDev.md)
|
|
29
32
|
Available JavaScript variables for student-related customizations. Documents the student user object accessible via `$nuxt.$store.state.auth.user` including fields like id, name, email, phone, grade, and profile image.
|
|
30
33
|
|
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
# CyberOcean Custom Student Profile Page
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
This document describes how to customize the **Student Profile Page** using a Vue.js component. The page allows students to view and edit their profile information including name, profile image, grade (if enabled), and password. It features a view/edit mode toggle, form validation, and updates the VueX store after saving.
|
|
6
|
+
|
|
7
|
+
> **IMPORTANT:** This component handles both **UI and logic** including API calls to save the profile and VueX store updates. The `additionalFields` object is used for extensible user data like grade.
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
The Student Profile Page displays the current user's information and allows editing. The parent component provides the user object, grade options, and grade list. Your component manages the edit state, form validation, and API submission.
|
|
12
|
+
|
|
13
|
+
**Key Features:**
|
|
14
|
+
- **View/Edit mode toggle** - Switch between viewing and editing profile
|
|
15
|
+
- **Profile header** - Avatar with user image or default icon
|
|
16
|
+
- **Profile image upload** - CLoader component for image upload (edit mode only)
|
|
17
|
+
- **Email display** - Read-only email field (view mode only)
|
|
18
|
+
- **Name fields** - Editable first name and last name
|
|
19
|
+
- **Grade selection** - Dropdown for grade (only if `gradeOption` is true and user is a student)
|
|
20
|
+
- **Password change** - New password and confirm password with real-time validation
|
|
21
|
+
- **Form validation** - Validates changes and password matching before save
|
|
22
|
+
- **API integration** - Calls `/api/users/update_my_profile` to save changes
|
|
23
|
+
- **VueX store update** - Updates `auth/setUser` after successful save
|
|
24
|
+
|
|
25
|
+
## Student Profile Page
|
|
26
|
+
|
|
27
|
+
### Component Structure
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
31
|
+
│ .profile-component │
|
|
32
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
33
|
+
│ │ .profile-header │ │
|
|
34
|
+
│ │ [Avatar] "My Profile" │ │
|
|
35
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
36
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
37
|
+
│ │ .profile-image-uploader (edit mode only) │ │
|
|
38
|
+
│ │ [CLoader FilesInput] │ │
|
|
39
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
40
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
41
|
+
│ │ .profile-content │ │
|
|
42
|
+
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
|
43
|
+
│ │ │ .info-section │ │ │
|
|
44
|
+
│ │ │ Email: user@example.com (view mode only) │ │ │
|
|
45
|
+
│ │ │ First Name: [___________] or "John" │ │ │
|
|
46
|
+
│ │ │ Last Name: [___________] or "Doe" │ │ │
|
|
47
|
+
│ │ │ Grade: [Dropdown___] (if gradeOption) │ │ │
|
|
48
|
+
│ │ │ Password: [___________] (edit mode only) │ │ │
|
|
49
|
+
│ │ │ Confirm: [___________] (edit mode only) │ │ │
|
|
50
|
+
│ │ └─────────────────────────────────────────────────────┘ │ │
|
|
51
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
52
|
+
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
53
|
+
│ │ .button-row │ │
|
|
54
|
+
│ │ [Edit Profile] or [Cancel] [Save] │ │
|
|
55
|
+
│ └─────────────────────────────────────────────────────────┘ │
|
|
56
|
+
└─────────────────────────────────────────────────────────────┘
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Available Props
|
|
60
|
+
|
|
61
|
+
| Prop | Type | Description |
|
|
62
|
+
|------|------|-------------|
|
|
63
|
+
| `user` | Object | The current logged-in user object (see structure below) |
|
|
64
|
+
| `gradeOption` | Boolean | Whether grade selection is enabled for this website |
|
|
65
|
+
| `itemsTunisiaGrades` | Array | List of available grades (see structure below) |
|
|
66
|
+
|
|
67
|
+
### The `user` Object
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
{
|
|
71
|
+
id: "user-id",
|
|
72
|
+
email: "user@example.com", // Read-only, displayed in view mode
|
|
73
|
+
first_name: "John",
|
|
74
|
+
last_name: "Doe",
|
|
75
|
+
customer_locale: "en", // User's language preference
|
|
76
|
+
image: { // Profile image (optional)
|
|
77
|
+
id: "image-id",
|
|
78
|
+
path: "/uploads/profile.jpg"
|
|
79
|
+
},
|
|
80
|
+
role: { // User role
|
|
81
|
+
key: "student" // "student" or "teacher"
|
|
82
|
+
},
|
|
83
|
+
additionalFields: { // Dynamic extensible fields
|
|
84
|
+
grade: "grade-9" // Grade key (if gradeOption is true)
|
|
85
|
+
// Can contain other custom fields in the future
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### The `additionalFields` Object
|
|
91
|
+
|
|
92
|
+
This is a **dynamic object** used to store extensible user data. Currently used for:
|
|
93
|
+
|
|
94
|
+
| Field | Type | Description |
|
|
95
|
+
|-------|------|-------------|
|
|
96
|
+
| `grade` | String | Grade key (e.g., "grade-9") - only used when `gradeOption` is true |
|
|
97
|
+
|
|
98
|
+
**Why `additionalFields`?**
|
|
99
|
+
- Allows adding new user properties without changing the core user schema
|
|
100
|
+
- Grade is stored here because it's specific to educational platforms
|
|
101
|
+
- Future extensions (country, school, etc.) can be added here
|
|
102
|
+
|
|
103
|
+
### The `itemsTunisiaGrades` Array
|
|
104
|
+
|
|
105
|
+
```js
|
|
106
|
+
[
|
|
107
|
+
{ key: "grade-7", title: "7th Grade" },
|
|
108
|
+
{ key: "grade-8", title: "8th Grade" },
|
|
109
|
+
{ key: "grade-9", title: "9th Grade" },
|
|
110
|
+
// ... more grades
|
|
111
|
+
]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Local State (data)
|
|
115
|
+
|
|
116
|
+
| Property | Type | Default | Description |
|
|
117
|
+
|----------|------|---------|-------------|
|
|
118
|
+
| `loading` | Boolean | `false` | Loading state during API call |
|
|
119
|
+
| `isEditing` | Boolean | `false` | Whether in edit mode |
|
|
120
|
+
| `newData` | Object | `{}` | Object holding modified field values |
|
|
121
|
+
| `originalData` | Object | `{}` | Original user data for reset |
|
|
122
|
+
| `newPassword` | String | `''` | New password input |
|
|
123
|
+
| `confirmPassword` | String | `''` | Confirm password input |
|
|
124
|
+
| `passwordError` | String | `''` | Password validation error message |
|
|
125
|
+
|
|
126
|
+
### Computed Properties
|
|
127
|
+
|
|
128
|
+
| Property | Description |
|
|
129
|
+
|----------|-------------|
|
|
130
|
+
| `grade` | Returns grade from `newData.additionalFields.grade` or `user.additionalFields.grade` |
|
|
131
|
+
| `first_name` | Returns first name from `newData` or `user` |
|
|
132
|
+
| `last_name` | Returns last name from `newData` or `user` |
|
|
133
|
+
| `customer_locale` | Returns locale from `newData` or `user` |
|
|
134
|
+
| `isFormValid` | Returns true if form has changes and password validation passes |
|
|
135
|
+
|
|
136
|
+
### Methods
|
|
137
|
+
|
|
138
|
+
| Method | Parameters | Description |
|
|
139
|
+
|--------|------------|-------------|
|
|
140
|
+
| `toggleEditMode()` | - | Toggles edit mode on/off, resets form when exiting |
|
|
141
|
+
| `cancelEdit()` | - | Resets form and exits edit mode |
|
|
142
|
+
| `resetForm()` | - | Clears `newData` (except password), resets password fields |
|
|
143
|
+
| `update(key, val)` | `key: String, val: any` | Updates a field in `newData` using `$set` |
|
|
144
|
+
| `updateCountry(val)` | `val: String` | Updates `additionalFields.countryKey` (for future use) |
|
|
145
|
+
| `updateGrade(val)` | `val: String` | Updates `additionalFields.grade` |
|
|
146
|
+
| `validatePassword()` | - | Validates password fields, returns true if valid |
|
|
147
|
+
| `saveProfile()` | - | Validates form, calls API, updates VueX store |
|
|
148
|
+
| `getGradeText(value)` | `value: String` | Converts grade key to display text |
|
|
149
|
+
|
|
150
|
+
### Page Sections
|
|
151
|
+
|
|
152
|
+
1. **Profile Header** (`.profile-header`)
|
|
153
|
+
- User avatar (image or default icon)
|
|
154
|
+
- "My Profile" title
|
|
155
|
+
|
|
156
|
+
2. **Profile Image Uploader** (`.profile-image-uploader`) - Edit mode only
|
|
157
|
+
- Uses `CLoader` component with `cpn="FilesInput"`
|
|
158
|
+
- Props: `v-model`, `:value`, `@input`, `SingleFile`, `tag="picture"`
|
|
159
|
+
|
|
160
|
+
3. **Info Section** (`.info-section`)
|
|
161
|
+
- **Email** - Display only (view mode only, read-only)
|
|
162
|
+
- **First Name** - Text field or display
|
|
163
|
+
- **Last Name** - Text field or display
|
|
164
|
+
- **Grade** - Select dropdown (only if `user.role.key === 'student'` AND `gradeOption === true`)
|
|
165
|
+
- **Password Section** - Two password fields (edit mode only)
|
|
166
|
+
|
|
167
|
+
4. **Button Row** (`.button-row`)
|
|
168
|
+
- View mode: "Edit Profile" button
|
|
169
|
+
- Edit mode: "Cancel" and "Save" buttons (Save disabled if `!isFormValid`)
|
|
170
|
+
|
|
171
|
+
### CLoader Component
|
|
172
|
+
|
|
173
|
+
The `CLoader` component is a platform-provided file uploader:
|
|
174
|
+
|
|
175
|
+
```html
|
|
176
|
+
<CLoader
|
|
177
|
+
cpn="FilesInput"
|
|
178
|
+
v-model="user.image"
|
|
179
|
+
:value="user.image.id"
|
|
180
|
+
@input="update('image', $event)"
|
|
181
|
+
label="Profile Photo"
|
|
182
|
+
SingleFile
|
|
183
|
+
tag="picture"
|
|
184
|
+
class="file-uploader"
|
|
185
|
+
/>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
| Prop | Description |
|
|
189
|
+
|------|-------------|
|
|
190
|
+
| `cpn` | Component type: `"FilesInput"` for file upload |
|
|
191
|
+
| `v-model` | Bound to user image object |
|
|
192
|
+
| `:value` | Current image ID |
|
|
193
|
+
| `@input` | Handler to update newData |
|
|
194
|
+
| `SingleFile` | Only allow one file |
|
|
195
|
+
| `tag` | File category: `"picture"` |
|
|
196
|
+
|
|
197
|
+
### API Integration
|
|
198
|
+
|
|
199
|
+
**Endpoint:** `POST /api/users/update_my_profile`
|
|
200
|
+
|
|
201
|
+
**Request body:**
|
|
202
|
+
```js
|
|
203
|
+
{
|
|
204
|
+
first_name: "John",
|
|
205
|
+
last_name: "Doe",
|
|
206
|
+
image: { id: "image-id", path: "/uploads/new.jpg" },
|
|
207
|
+
password: "newpassword123", // Only if changing password
|
|
208
|
+
additionalFields: {
|
|
209
|
+
grade: "grade-10" // Only if gradeOption is true
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**After successful save:**
|
|
215
|
+
```js
|
|
216
|
+
this.$store.dispatch('auth/setUser', updatedUser);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Password Validation
|
|
220
|
+
|
|
221
|
+
The `isFormValid` computed property performs real-time validation:
|
|
222
|
+
|
|
223
|
+
1. If neither password field is filled → Valid (no password change)
|
|
224
|
+
2. If only one field is filled → Invalid, show appropriate error
|
|
225
|
+
3. If both filled but don't match → Invalid, show "Passwords do not match"
|
|
226
|
+
4. If both filled and match → Valid
|
|
227
|
+
|
|
228
|
+
### Available translations (Use what you need)
|
|
229
|
+
|
|
230
|
+
- `profile.my-profile` → "My Profile"
|
|
231
|
+
- `profile.first-name` → "First Name"
|
|
232
|
+
- `profile.last-name` → "Last Name"
|
|
233
|
+
- `profile.grade` → "Grade"
|
|
234
|
+
- `profile.change-password` → "Change Password"
|
|
235
|
+
- `profile.new-password` → "New Password"
|
|
236
|
+
- `profile.confirm-password` → "Confirm Password"
|
|
237
|
+
- `profile.edit-profile` → "Edit Profile"
|
|
238
|
+
- `profile.cancel` → "Cancel"
|
|
239
|
+
- `profile.save` → "Save"
|
|
240
|
+
- `toast.profile-updated-successfully` → "Profile updated successfully"
|
|
241
|
+
- `toast.error-saving-profile` → "Error saving profile"
|
|
242
|
+
|
|
243
|
+
If you want another text, just put it in English.
|
|
244
|
+
|
|
245
|
+
### Example Student Profile Page:
|
|
246
|
+
```js
|
|
247
|
+
module.exports = {
|
|
248
|
+
name: "ProfilePage",
|
|
249
|
+
props: [
|
|
250
|
+
'itemsTunisiaGrades',
|
|
251
|
+
'gradeOption',
|
|
252
|
+
'user',
|
|
253
|
+
],
|
|
254
|
+
template: /* html */`
|
|
255
|
+
<div class="profile-component">
|
|
256
|
+
<div class="profile-header">
|
|
257
|
+
<div class="user-avatar">
|
|
258
|
+
<img v-if="user.image" :src="user.image.path" alt="Profile" class="avatar-img" />
|
|
259
|
+
<v-icon v-else size="24" color="white">mdi-account</v-icon>
|
|
260
|
+
</div>
|
|
261
|
+
<h2 class="profile-title">{{ $t("profile.my-profile") }}</h2>
|
|
262
|
+
</div>
|
|
263
|
+
<!-- Profile Image Section (Only visible in edit mode) -->
|
|
264
|
+
<div v-if="isEditing" class="profile-image-uploader">
|
|
265
|
+
<CLoader
|
|
266
|
+
cpn="FilesInput"
|
|
267
|
+
v-model="user.image"
|
|
268
|
+
:value="user.image.id"
|
|
269
|
+
@input="update('image', $event)"
|
|
270
|
+
label="Profile Photo" SingleFile tag="picture" class="file-uploader"></CLoader>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div class="profile-content">
|
|
274
|
+
|
|
275
|
+
<div class="info-section">
|
|
276
|
+
<!-- Email -->
|
|
277
|
+
<div v-if="!isEditing" class="info-field">
|
|
278
|
+
<label class="field-label"> E-mail</label>
|
|
279
|
+
<div class="field-container">
|
|
280
|
+
<div class="field-display" style="font-weight: bold;">{{ user.email }}</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<!-- First Name -->
|
|
285
|
+
<div class="info-field">
|
|
286
|
+
<label class="field-label">{{ $t("profile.first-name") }}</label>
|
|
287
|
+
<div class="field-container">
|
|
288
|
+
<div v-if="!isEditing" class="field-display">{{ first_name }}</div>
|
|
289
|
+
<v-text-field v-else :value="first_name" @input="update('first_name', $event)" outlined hide-details dense rounded class="profile-input-field"
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<!-- Last Name -->
|
|
295
|
+
<div class="info-field">
|
|
296
|
+
<label class="field-label">{{ $t("profile.last-name") }}</label>
|
|
297
|
+
<div class="field-container">
|
|
298
|
+
<div v-if="!isEditing" class="field-display">{{ last_name }}</div>
|
|
299
|
+
<v-text-field v-else :value="last_name" @input="update('last_name', $event)" outlined hide-details dense rounded class="profile-input-field"
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<!-- Grade - Only visible for students when gradeOption is true -->
|
|
305
|
+
<div class="info-field" v-if="user.role && user.role.key === 'student' && gradeOption">
|
|
306
|
+
<label class="field-label">{{ $t("profile.grade") }}</label>
|
|
307
|
+
<div class="field-container">
|
|
308
|
+
<div v-if="!isEditing" class="field-display">
|
|
309
|
+
{{ getGradeText(grade) || 'Not specified' }}
|
|
310
|
+
</div>
|
|
311
|
+
<v-select v-else :value="grade" @input="updateGrade($event)" :items="itemsTunisiaGrades"
|
|
312
|
+
outlined hide-details dense rounded class="profile-input-field" item-text="title" item-value="key" />
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<!-- Password Section -->
|
|
317
|
+
<div class="info-field password-field" v-if="isEditing">
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
<label class="field-label">{{ $t("profile.change-password") }}</label>
|
|
321
|
+
<div class="password-section">
|
|
322
|
+
<v-text-field v-model="newPassword" type="password" outlined hide-details dense rounded class="profile-input-field"
|
|
323
|
+
:placeholder="$t('profile.new-password')" />
|
|
324
|
+
<v-text-field v-model="confirmPassword" type="password" outlined hide-details dense rounded class="profile-input-field"
|
|
325
|
+
:placeholder="$t('profile.confirm-password')"
|
|
326
|
+
:error-messages="passwordError ? [passwordError] : []" />
|
|
327
|
+
|
|
328
|
+
<div class="password-error" v-if="passwordError">
|
|
329
|
+
{{ passwordError }}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
<div class="button-row">
|
|
336
|
+
<v-btn v-if="!isEditing"
|
|
337
|
+
color="primary"
|
|
338
|
+
@click="toggleEditMode"
|
|
339
|
+
depressed
|
|
340
|
+
rounded
|
|
341
|
+
class="action-button"
|
|
342
|
+
>
|
|
343
|
+
<v-icon left size="18">mdi-pencil</v-icon>
|
|
344
|
+
{{ $t("profile.edit-profile") }}
|
|
345
|
+
</v-btn>
|
|
346
|
+
<template v-else>
|
|
347
|
+
<v-btn
|
|
348
|
+
class="mr-4 action-button"
|
|
349
|
+
text
|
|
350
|
+
@click="cancelEdit"
|
|
351
|
+
rounded
|
|
352
|
+
>
|
|
353
|
+
{{ $t("profile.cancel") }}
|
|
354
|
+
</v-btn>
|
|
355
|
+
<v-btn
|
|
356
|
+
color="primary"
|
|
357
|
+
@click="saveProfile"
|
|
358
|
+
depressed
|
|
359
|
+
rounded
|
|
360
|
+
:disabled="!isFormValid"
|
|
361
|
+
class="action-button"
|
|
362
|
+
>
|
|
363
|
+
<v-icon left size="18">mdi-content-save</v-icon>
|
|
364
|
+
{{ $t("profile.save") }}
|
|
365
|
+
</v-btn>
|
|
366
|
+
</template>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
`,
|
|
371
|
+
|
|
372
|
+
data: /* js */`
|
|
373
|
+
function() {
|
|
374
|
+
return {
|
|
375
|
+
loading: false,
|
|
376
|
+
isEditing: false,
|
|
377
|
+
newData: {},
|
|
378
|
+
originalData: {},
|
|
379
|
+
newPassword: '',
|
|
380
|
+
confirmPassword: '',
|
|
381
|
+
passwordError: '',
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
`,
|
|
385
|
+
mounted: /* js */`
|
|
386
|
+
function() {
|
|
387
|
+
this.originalData = {
|
|
388
|
+
first_name: this.user.first_name,
|
|
389
|
+
last_name: this.user.last_name,
|
|
390
|
+
customer_locale: this.user.customer_locale,
|
|
391
|
+
image: this.user.image,
|
|
392
|
+
additionalFields: this.user.additionalFields || {}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
`,
|
|
396
|
+
computed: /* js */`
|
|
397
|
+
{
|
|
398
|
+
grade() {
|
|
399
|
+
return this.newData.additionalFields?.grade
|
|
400
|
+
? this.newData.additionalFields.grade
|
|
401
|
+
: this.user?.additionalFields?.grade || '';
|
|
402
|
+
},
|
|
403
|
+
first_name() {
|
|
404
|
+
return this.newData.first_name
|
|
405
|
+
? this.newData.first_name
|
|
406
|
+
: this.user.first_name;
|
|
407
|
+
},
|
|
408
|
+
last_name() {
|
|
409
|
+
return this.newData.last_name
|
|
410
|
+
? this.newData.last_name
|
|
411
|
+
: this.user.last_name;
|
|
412
|
+
},
|
|
413
|
+
customer_locale() {
|
|
414
|
+
return this.newData.customer_locale
|
|
415
|
+
? this.newData.customer_locale
|
|
416
|
+
: this.user.customer_locale;
|
|
417
|
+
},
|
|
418
|
+
isFormValid() {
|
|
419
|
+
// Only require changes in edit mode
|
|
420
|
+
if (!this.isEditing) return false;
|
|
421
|
+
|
|
422
|
+
// Check if form has changes
|
|
423
|
+
const hasChanges = Object.keys(this.newData).length > 0 || this.newPassword || this.confirmPassword;
|
|
424
|
+
|
|
425
|
+
// Real-time password validation
|
|
426
|
+
let passwordValid = true;
|
|
427
|
+
if (this.newPassword || this.confirmPassword) {
|
|
428
|
+
// If either password field is filled, both must be filled and match
|
|
429
|
+
passwordValid = this.newPassword && this.confirmPassword && this.newPassword === this.confirmPassword;
|
|
430
|
+
|
|
431
|
+
// Update error message for immediate feedback
|
|
432
|
+
if (this.newPassword && !this.confirmPassword) {
|
|
433
|
+
this.passwordError = 'Please confirm your password';
|
|
434
|
+
} else if (!this.newPassword && this.confirmPassword) {
|
|
435
|
+
this.passwordError = 'Please enter your new password';
|
|
436
|
+
} else if (this.newPassword !== this.confirmPassword) {
|
|
437
|
+
this.passwordError = 'Passwords do not match';
|
|
438
|
+
} else {
|
|
439
|
+
this.passwordError = '';
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return hasChanges && passwordValid;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
`,
|
|
447
|
+
methods: /* js */`
|
|
448
|
+
{
|
|
449
|
+
toggleEditMode() {
|
|
450
|
+
this.isEditing = !this.isEditing;
|
|
451
|
+
if (!this.isEditing) {
|
|
452
|
+
this.resetForm();
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
cancelEdit() {
|
|
456
|
+
this.resetForm();
|
|
457
|
+
this.isEditing = false;
|
|
458
|
+
},
|
|
459
|
+
resetForm() {
|
|
460
|
+
// Create a new object without the password property
|
|
461
|
+
const newDataWithoutPassword = { ...this.newData };
|
|
462
|
+
if (newDataWithoutPassword.hasOwnProperty('password')) {
|
|
463
|
+
delete newDataWithoutPassword.password;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
this.newData = newDataWithoutPassword;
|
|
467
|
+
this.newPassword = '';
|
|
468
|
+
this.confirmPassword = '';
|
|
469
|
+
this.passwordError = '';
|
|
470
|
+
},
|
|
471
|
+
update(key, val) {
|
|
472
|
+
this.$set(this.newData, key, val);
|
|
473
|
+
},
|
|
474
|
+
updateCountry(val) {
|
|
475
|
+
if (!this.newData.additionalFields) {
|
|
476
|
+
this.$set(this.newData, 'additionalFields', {});
|
|
477
|
+
}
|
|
478
|
+
this.$set(this.newData.additionalFields, 'countryKey', val);
|
|
479
|
+
},
|
|
480
|
+
updateGrade(val) {
|
|
481
|
+
if (!this.newData.additionalFields) {
|
|
482
|
+
this.$set(this.newData, 'additionalFields', {});
|
|
483
|
+
}
|
|
484
|
+
this.$set(this.newData.additionalFields, 'grade', val);
|
|
485
|
+
},
|
|
486
|
+
// Modify the validatePassword method to only add password to newData if both fields are filled
|
|
487
|
+
validatePassword() {
|
|
488
|
+
// Clear previous error
|
|
489
|
+
this.passwordError = '';
|
|
490
|
+
|
|
491
|
+
// If both fields are empty, no password change is intended
|
|
492
|
+
if (!this.newPassword && !this.confirmPassword) {
|
|
493
|
+
// Remove password from newData if it exists
|
|
494
|
+
if (this.newData.hasOwnProperty('password')) {
|
|
495
|
+
delete this.newData.password;
|
|
496
|
+
}
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// If only one field is filled
|
|
501
|
+
if (this.newPassword && !this.confirmPassword) {
|
|
502
|
+
this.passwordError = 'Please confirm your password';
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!this.newPassword && this.confirmPassword) {
|
|
507
|
+
this.passwordError = 'Please enter your new password';
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Check if passwords match
|
|
512
|
+
if (this.newPassword !== this.confirmPassword) {
|
|
513
|
+
this.passwordError = 'Passwords do not match';
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Password is valid, update newData only if both fields are filled
|
|
518
|
+
if (this.newPassword && this.confirmPassword) {
|
|
519
|
+
this.update('password', this.newPassword);
|
|
520
|
+
}
|
|
521
|
+
return true;
|
|
522
|
+
},
|
|
523
|
+
saveProfile() {
|
|
524
|
+
if (!this.isFormValid) return;
|
|
525
|
+
|
|
526
|
+
// Validate password before saving
|
|
527
|
+
if (!this.validatePassword()) return;
|
|
528
|
+
|
|
529
|
+
// Create a copy of the data to send
|
|
530
|
+
const dataToSend = { ...this.newData };
|
|
531
|
+
|
|
532
|
+
// Always include first_name and last_name
|
|
533
|
+
dataToSend.first_name = this.first_name;
|
|
534
|
+
dataToSend.last_name = this.last_name;
|
|
535
|
+
|
|
536
|
+
// Only include password if both fields are filled
|
|
537
|
+
if (!this.newPassword && !this.confirmPassword) {
|
|
538
|
+
delete dataToSend.password;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
this.loading = true;
|
|
542
|
+
this.$dataCaller("post", "/api/users/update_my_profile" ,dataToSend)
|
|
543
|
+
.then((response) => {
|
|
544
|
+
this.loading = false;
|
|
545
|
+
this.isEditing = false;
|
|
546
|
+
this.resetForm();
|
|
547
|
+
this.$toast.success(this.$t('toast.profile-updated-successfully'));
|
|
548
|
+
|
|
549
|
+
// IMPORTANT: Update the VueX Store with the new user data
|
|
550
|
+
const updatedUser = {
|
|
551
|
+
...this.user,
|
|
552
|
+
...dataToSend,
|
|
553
|
+
additionalFields: {
|
|
554
|
+
...this.user.additionalFields,
|
|
555
|
+
...(dataToSend.additionalFields || {})
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
this.$store.dispatch('auth/setUser', updatedUser);
|
|
559
|
+
})
|
|
560
|
+
.catch((error) => {
|
|
561
|
+
this.loading = false;
|
|
562
|
+
this.$toast.error(error?.message || this.$t('toast.error-saving-profile'));
|
|
563
|
+
});
|
|
564
|
+
},
|
|
565
|
+
getGradeText(value) {
|
|
566
|
+
const grade = this.itemsTunisiaGrades.find(item => item.key === value);
|
|
567
|
+
return grade ? grade.title : value;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
`,
|
|
571
|
+
style: /* css */`
|
|
572
|
+
.profile-component .profile-input-field>.v-input__control>.v-input__slot>.v-text-field__slot {
|
|
573
|
+
padding-left: 10px !important;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/* Base Styles - Apply to both themes */
|
|
577
|
+
.profile-component {
|
|
578
|
+
padding: 20px;
|
|
579
|
+
transition: all 0.3s ease;
|
|
580
|
+
min-height: 100%;
|
|
581
|
+
max-width: 800px;
|
|
582
|
+
margin: 0 auto;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/* Profile Image Section */
|
|
586
|
+
.profile-component .profile-image-uploader {
|
|
587
|
+
max-width: 220px;
|
|
588
|
+
margin: 20px auto;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/* Profile Header with User Avatar */
|
|
592
|
+
.profile-component .profile-header {
|
|
593
|
+
display: flex;
|
|
594
|
+
align-items: center;
|
|
595
|
+
margin-bottom: 24px;
|
|
596
|
+
padding-bottom: 16px;
|
|
597
|
+
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.profile-component .theme--dark .profile-header {
|
|
601
|
+
border-bottom-color: rgba(255, 255, 255, 0.1);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.profile-component .user-avatar {
|
|
605
|
+
width: 40px;
|
|
606
|
+
height: 40px;
|
|
607
|
+
background: var(--v-primary-base);
|
|
608
|
+
border-radius: 50%;
|
|
609
|
+
display: flex;
|
|
610
|
+
align-items: center;
|
|
611
|
+
justify-content: center;
|
|
612
|
+
margin-right: 16px;
|
|
613
|
+
flex-shrink: 0;
|
|
614
|
+
overflow: hidden;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.profile-component .avatar-img {
|
|
618
|
+
width: 100%;
|
|
619
|
+
height: 100%;
|
|
620
|
+
object-fit: cover;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.profile-component .profile-title {
|
|
624
|
+
font-size: 20px;
|
|
625
|
+
font-weight: 600;
|
|
626
|
+
margin: 0;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/* Content Section */
|
|
630
|
+
.profile-component .profile-content {
|
|
631
|
+
width: 100%;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.profile-component .info-section {
|
|
635
|
+
display: flex;
|
|
636
|
+
flex-direction: column;
|
|
637
|
+
gap: 20px;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.profile-component .info-field {
|
|
641
|
+
display: flex;
|
|
642
|
+
flex-direction: column;
|
|
643
|
+
gap: 8px;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.profile-component .field-label {
|
|
647
|
+
font-size: 14px;
|
|
648
|
+
font-weight: 500;
|
|
649
|
+
color: #666;
|
|
650
|
+
letter-spacing: 0.3px;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.profile-component .field-container {
|
|
654
|
+
width: 100%;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.profile-component .field-display {
|
|
658
|
+
font-size: 16px;
|
|
659
|
+
padding: 6px 0;
|
|
660
|
+
border-bottom: 1px solid #eee;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.profile-component .profile-input-field {
|
|
664
|
+
margin-top: 0;
|
|
665
|
+
border-radius: 8px !important;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/* Password Section */
|
|
669
|
+
.profile-component .password-section {
|
|
670
|
+
display: flex;
|
|
671
|
+
flex-direction: column;
|
|
672
|
+
gap: 16px;
|
|
673
|
+
padding: 10px 0;
|
|
674
|
+
margin-top: 5px;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.profile-component .password-error {
|
|
678
|
+
color: #ff5252;
|
|
679
|
+
font-size: 14px;
|
|
680
|
+
margin-top: 4px;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* Button Styles */
|
|
684
|
+
.profile-component .button-row {
|
|
685
|
+
display: flex;
|
|
686
|
+
justify-content: flex-end;
|
|
687
|
+
gap: 16px;
|
|
688
|
+
margin-top: 40px;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.profile-component .action-button {
|
|
692
|
+
text-transform: none;
|
|
693
|
+
font-weight: 500;
|
|
694
|
+
letter-spacing: 0.3px;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
/* Responsive Styles */
|
|
700
|
+
@media (min-width: 768px) {
|
|
701
|
+
.profile-component .info-field {
|
|
702
|
+
flex-direction: row;
|
|
703
|
+
align-items: center;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.profile-component .field-label {
|
|
707
|
+
width: 150px;
|
|
708
|
+
text-align: right;
|
|
709
|
+
padding-right: 20px;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.profile-component .field-container {
|
|
713
|
+
flex: 1;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
@media (max-width: 767px) {
|
|
718
|
+
.profile-component .profile-card {
|
|
719
|
+
padding: 30px 20px;
|
|
720
|
+
border-radius: 15px;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.profile-component .section-title {
|
|
724
|
+
font-size: 20px;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.profile-component .action-button {
|
|
728
|
+
padding: 10px 20px;
|
|
729
|
+
min-width: 100px;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
.profile-component .button-row {
|
|
733
|
+
justify-content: center;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
`
|
|
737
|
+
}
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
## Vue Component Format
|
|
742
|
+
|
|
743
|
+
### Basic Structure
|
|
744
|
+
|
|
745
|
+
```js
|
|
746
|
+
module.exports = {
|
|
747
|
+
name: "MyComponent",
|
|
748
|
+
|
|
749
|
+
props: {
|
|
750
|
+
title: { required: true },
|
|
751
|
+
count: { default: 0 }
|
|
752
|
+
},
|
|
753
|
+
// Or in array format:
|
|
754
|
+
// props: [
|
|
755
|
+
// 'title',
|
|
756
|
+
// 'count'
|
|
757
|
+
// ],
|
|
758
|
+
|
|
759
|
+
template: /* html */`
|
|
760
|
+
<div class="my-component">
|
|
761
|
+
<h1>{{ title }}</h1>
|
|
762
|
+
<button @click="increment">Count: {{ counter }}</button>
|
|
763
|
+
</div>
|
|
764
|
+
`,
|
|
765
|
+
|
|
766
|
+
style: /* css */`
|
|
767
|
+
.my-component { padding: 20px; }
|
|
768
|
+
.my-component h1 { color: blue; }
|
|
769
|
+
`,
|
|
770
|
+
|
|
771
|
+
data: /* js */`
|
|
772
|
+
function() {
|
|
773
|
+
return {
|
|
774
|
+
counter: 0
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
`,
|
|
778
|
+
|
|
779
|
+
computed: /* js */`
|
|
780
|
+
{
|
|
781
|
+
counterText() {
|
|
782
|
+
return this.counter + " Counts";
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
`,
|
|
786
|
+
|
|
787
|
+
methods: /* js */`
|
|
788
|
+
{
|
|
789
|
+
increment() {
|
|
790
|
+
this.counter++;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
`,
|
|
794
|
+
|
|
795
|
+
mounted: /* js */`
|
|
796
|
+
function() {
|
|
797
|
+
console.log('Component mounted!');
|
|
798
|
+
}
|
|
799
|
+
`
|
|
800
|
+
};
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
### Available Fields
|
|
804
|
+
|
|
805
|
+
| Field | Format | Description |
|
|
806
|
+
|-------|--------|-------------|
|
|
807
|
+
| `name` | String | Component name (required) |
|
|
808
|
+
| `props` | Object | Props definition (not a string) |
|
|
809
|
+
| `template` | Template literal | HTML template with Vue syntax |
|
|
810
|
+
| `style` | Template literal | CSS styles for the component |
|
|
811
|
+
| `data` | Template literal | Function returning initial state |
|
|
812
|
+
| `computed` | Template literal | Object with computed properties |
|
|
813
|
+
| `watch` | Template literal | Object with watchers |
|
|
814
|
+
| `methods` | Template literal | Object with methods |
|
|
815
|
+
| `mounted` | Template literal | Lifecycle hook function |
|
|
816
|
+
| `created`, `beforeMount`, `beforeUpdate`, `updated`, `beforeDestroy`, `destroyed` | Template literal | Other lifecycle hooks |
|
|
817
|
+
|
|
818
|
+
### Key Rules
|
|
819
|
+
|
|
820
|
+
1. **Use template literals** (backticks) for `template`, `style`, `data`, `methods`, etc.
|
|
821
|
+
2. **Props is an object**, not a template literal
|
|
822
|
+
3. **Comments are optional** but recommended: `/* html */`, `/* css */`, `/* js */`
|
|
823
|
+
4. **Unused fields** can be omitted or set to `null`
|
|
824
|
+
5. **Empty file marker**: `/* EMPTY FILE */` for placeholder files
|
|
825
|
+
|
|
826
|
+
### Minimal Example
|
|
827
|
+
|
|
828
|
+
```js
|
|
829
|
+
module.exports = {
|
|
830
|
+
name: "HelloWorld",
|
|
831
|
+
template: /* html */`
|
|
832
|
+
<div>Hello, World!</div>
|
|
833
|
+
`
|
|
834
|
+
};
|
|
835
|
+
```
|