itube-specs 0.0.1075 → 0.0.1076
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/SITE_UPDATES.md +19 -0
- package/assets/scss/themes/dark/_theme.scss +1 -0
- package/assets/scss/themes/light/_theme.scss +1 -0
- package/components/auth/login.vue +23 -32
- package/components/auth/recovery.vue +7 -13
- package/components/auth/register.vue +16 -25
- package/components/playlist/add.vue +8 -7
- package/components/playlist/input.vue +5 -2
- package/components/profile/password.vue +17 -26
- package/components/profile/setup.vue +11 -11
- package/components/report/main.vue +25 -27
- package/components/ui/input.vue +64 -11
- package/composables/use-form-errors.ts +82 -0
- package/composables/use-user.ts +5 -5
- package/i18n/locales/en-US.json +3 -0
- package/package.json +1 -1
- package/pages/user/recovery-password.vue +9 -15
- package/runtime/index.ts +1 -0
- package/runtime/utils/server/__tests__/parse-api-field-errors.test.ts +38 -0
- package/runtime/utils/server/parse-api-field-errors.ts +21 -0
package/SITE_UPDATES.md
CHANGED
|
@@ -11,6 +11,25 @@
|
|
|
11
11
|
коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
|
|
12
12
|
кому именно (всем / сайтам с N), что будет, если не сделать.
|
|
13
13
|
|
|
14
|
+
## 0.0.1076 — 2026-11-09
|
|
15
|
+
|
|
16
|
+
- **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hdsex-remaster), срочно:** без нового токена сборка
|
|
17
|
+
падает на `Undefined variable`. Добавить `$input-error` — цвет текста ошибки под полем (тёмная — `$color-red-500`,
|
|
18
|
+
светлая — `$color-red-600`).
|
|
19
|
+
- **Всем, у кого есть компонентные тесты форм (`test/components/{auth,profile,playlist,report}`):** ошибки
|
|
20
|
+
валидации и ошибки бэка по конкретному полю теперь выводятся текстом под полем (`UiInput` с `error`-строкой,
|
|
21
|
+
композабл `useFormErrors`), а не снекбаром; снекбар — только для общих ошибок формы. Тесты, ждущие
|
|
22
|
+
`showError('error_email' | 'error_username' | 'error_password' | 'playlist_name_exists')` и снекбар на пустой
|
|
23
|
+
сабмит жалобы, упадут. Взять файлы из gay-cool-new (коммит «Field errors under inputs instead of snackbar»):
|
|
24
|
+
`auth/login`, `auth/register`, `auth/recovery`, `playlist/add`, `report/main`, `profile/password`,
|
|
25
|
+
`profile/setup` — там же новые кейсы (пусто → «обязательное поле», ошибка бэка по полю, сброс при вводе).
|
|
26
|
+
- **Сайтам со своим кодом на `useUser().changePassword / changeEmail / recoveryPassword` (hd-sex-new — при
|
|
27
|
+
переезде на свежий слой):** они больше не показывают снекбар ошибки сами, а пробрасывают ошибку. Вызывать с
|
|
28
|
+
`await` в `try/catch` и отдавать её в `useFormErrors().applyApiError(err, '<ключ снекбара>')`, иначе ошибка
|
|
29
|
+
уйдёт в консоль как необработанная и пользователь ничего не увидит.
|
|
30
|
+
- **Всем — к сведению:** новый общий ключ `field_error.required` («This field is required») пока только в `en-US`;
|
|
31
|
+
перевод — через CSV-мост слоя.
|
|
32
|
+
|
|
14
33
|
## 0.0.1075 — 2026-11-06
|
|
15
34
|
|
|
16
35
|
- **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hdsex-remaster), срочно:** без нового токена сборка
|
|
@@ -105,6 +105,7 @@ $input-s-border: transparent;
|
|
|
105
105
|
$input-s-border--focus: $color-zinc-600;
|
|
106
106
|
$input-text--disabled: $color-zinc-400;
|
|
107
107
|
$input-border--error: $color-red-500;
|
|
108
|
+
$input-error: $color-red-500;
|
|
108
109
|
$input-autofill-text: $color-white;
|
|
109
110
|
$input-button-text: $color-zinc-500;
|
|
110
111
|
$input-button-text--hover: $color-white;
|
|
@@ -116,6 +116,7 @@ $input-s-border: transparent;
|
|
|
116
116
|
$input-s-border--focus: $color-indigo-soft;
|
|
117
117
|
$input-text--disabled: $color-gray-400;
|
|
118
118
|
$input-border--error: $color-red-500;
|
|
119
|
+
$input-error: $color-red-600;
|
|
119
120
|
$input-autofill-text: $color-gray-900;
|
|
120
121
|
$input-button-text: $color-gray-500;
|
|
121
122
|
$input-button-text--hover: $color-gray-500;
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
:label="t('auth.username')"
|
|
11
11
|
:placeholder="t('auth.username')"
|
|
12
12
|
hide-label
|
|
13
|
-
:error="
|
|
13
|
+
:error="errors.username"
|
|
14
14
|
name="login-name"
|
|
15
15
|
enterkeyhint="next"
|
|
16
|
-
@update:error="(
|
|
16
|
+
@update:error="clearError('username')"
|
|
17
17
|
@enter="passwordRef?.focus()"
|
|
18
18
|
/>
|
|
19
19
|
<UiInput
|
|
@@ -22,10 +22,11 @@
|
|
|
22
22
|
:label="t('auth.password')"
|
|
23
23
|
:placeholder="t('auth.password')"
|
|
24
24
|
hide-label
|
|
25
|
-
:error="
|
|
25
|
+
:error="errors.password_hash"
|
|
26
|
+
name="login-password"
|
|
26
27
|
type="password"
|
|
27
28
|
enterkeyhint="go"
|
|
28
|
-
@update:error="(
|
|
29
|
+
@update:error="clearError('password_hash')"
|
|
29
30
|
@enter="login"
|
|
30
31
|
/>
|
|
31
32
|
|
|
@@ -74,10 +75,7 @@ const form = ref({
|
|
|
74
75
|
password_hash: '',
|
|
75
76
|
});
|
|
76
77
|
|
|
77
|
-
const
|
|
78
|
-
username: false,
|
|
79
|
-
password_hash: false,
|
|
80
|
-
});
|
|
78
|
+
const { errors, clearError, resetErrors, validate, applyApiError } = useFormErrors(['username', 'password_hash'] as const);
|
|
81
79
|
|
|
82
80
|
const emit = defineEmits<{
|
|
83
81
|
(eventName: 'forgot'): void;
|
|
@@ -94,33 +92,26 @@ function onCreateClick() {
|
|
|
94
92
|
|
|
95
93
|
const loading = ref(false);
|
|
96
94
|
|
|
97
|
-
const {
|
|
95
|
+
const { show, resetSnackbar } = useSnackbar();
|
|
98
96
|
|
|
99
97
|
async function login() {
|
|
100
98
|
resetSnackbar();
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
useAuthPopup().closeAuthPopup();
|
|
118
|
-
show('Welcome back!');
|
|
119
|
-
} catch (error) {
|
|
120
|
-
setErrorState(error);
|
|
121
|
-
} finally {
|
|
122
|
-
loading.value = false;
|
|
123
|
-
}
|
|
99
|
+
resetErrors();
|
|
100
|
+
|
|
101
|
+
const isUsernameValid = validate('username', form.value.username, validateUsername, 'snackbar.error_username');
|
|
102
|
+
const isPasswordValid = validate('password_hash', form.value.password_hash, validatePassword, 'snackbar.error_password');
|
|
103
|
+
|
|
104
|
+
if (!isUsernameValid || !isPasswordValid) return;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
loading.value = true;
|
|
108
|
+
await useUser(AuthorizationApiService).login(form.value);
|
|
109
|
+
useAuthPopup().closeAuthPopup();
|
|
110
|
+
show('Welcome back!');
|
|
111
|
+
} catch (error) {
|
|
112
|
+
applyApiError(error);
|
|
113
|
+
} finally {
|
|
114
|
+
loading.value = false;
|
|
124
115
|
}
|
|
125
116
|
}
|
|
126
117
|
</script>
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
:label="$t('email')"
|
|
16
16
|
:placeholder="$t('email')"
|
|
17
17
|
hide-label
|
|
18
|
-
:error="
|
|
18
|
+
:error="errors.email"
|
|
19
19
|
type="email"
|
|
20
20
|
inputmode="email"
|
|
21
21
|
enterkeyhint="send"
|
|
22
22
|
autocomplete="email"
|
|
23
|
-
@update:error="(
|
|
23
|
+
@update:error="clearError('email')"
|
|
24
24
|
@enter="submit"
|
|
25
25
|
/>
|
|
26
26
|
|
|
@@ -56,9 +56,7 @@ const form = ref({
|
|
|
56
56
|
token: '',
|
|
57
57
|
} as IPasswordForm);
|
|
58
58
|
|
|
59
|
-
const
|
|
60
|
-
email: false,
|
|
61
|
-
});
|
|
59
|
+
const { errors, clearError, resetErrors, validate, applyApiError } = useFormErrors(['email'] as const);
|
|
62
60
|
|
|
63
61
|
const emit = defineEmits<{
|
|
64
62
|
(eventName: 'login'): void
|
|
@@ -70,7 +68,7 @@ function onLoginClick() {
|
|
|
70
68
|
|
|
71
69
|
const loading = ref(false);
|
|
72
70
|
|
|
73
|
-
const {
|
|
71
|
+
const { show, resetSnackbar } = useSnackbar();
|
|
74
72
|
|
|
75
73
|
// Загружаем reCAPTCHA при открытии формы (onMounted) — даёт Google время собрать данные о поведении
|
|
76
74
|
onMounted(async () => {
|
|
@@ -83,13 +81,9 @@ onMounted(async () => {
|
|
|
83
81
|
|
|
84
82
|
async function submit() {
|
|
85
83
|
resetSnackbar();
|
|
86
|
-
|
|
84
|
+
resetErrors();
|
|
87
85
|
|
|
88
|
-
if (!
|
|
89
|
-
error.value.email = true;
|
|
90
|
-
showError('error_email');
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
86
|
+
if (!validate('email', form.value.email, validateEmail, 'snackbar.error_email')) return;
|
|
93
87
|
|
|
94
88
|
try {
|
|
95
89
|
loading.value = true;
|
|
@@ -98,7 +92,7 @@ async function submit() {
|
|
|
98
92
|
useAuthPopup().closeAuthPopup();
|
|
99
93
|
show('Check your email');
|
|
100
94
|
} catch (error) {
|
|
101
|
-
|
|
95
|
+
applyApiError(error);
|
|
102
96
|
} finally {
|
|
103
97
|
loading.value = false;
|
|
104
98
|
}
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
:placeholder="t('email')"
|
|
16
16
|
:label="t('email')"
|
|
17
17
|
hide-label
|
|
18
|
-
:error="
|
|
18
|
+
:error="errors.email"
|
|
19
19
|
type="email"
|
|
20
20
|
inputmode="email"
|
|
21
21
|
enterkeyhint="next"
|
|
22
22
|
autocomplete="email"
|
|
23
|
-
@update:error="(
|
|
23
|
+
@update:error="clearError('email')"
|
|
24
24
|
@enter="usernameRef?.focus()"
|
|
25
25
|
/>
|
|
26
26
|
<UiInput
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
:label="t('auth.username')"
|
|
30
30
|
:placeholder="t('auth.username')"
|
|
31
31
|
hide-label
|
|
32
|
-
:error="
|
|
32
|
+
:error="errors.username"
|
|
33
33
|
name="login-name"
|
|
34
34
|
enterkeyhint="next"
|
|
35
|
-
@update:error="(
|
|
35
|
+
@update:error="clearError('username')"
|
|
36
36
|
@enter="passwordRef?.focus()"
|
|
37
37
|
/>
|
|
38
38
|
<UiInput
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
:label="t('auth.password')"
|
|
42
42
|
:placeholder="t('auth.password')"
|
|
43
43
|
hide-label
|
|
44
|
-
:error="
|
|
44
|
+
:error="errors.password_hash"
|
|
45
45
|
type="password"
|
|
46
46
|
enterkeyhint="send"
|
|
47
47
|
autocomplete="new-password"
|
|
48
|
-
@update:error="(
|
|
48
|
+
@update:error="clearError('password_hash')"
|
|
49
49
|
@enter="submit"
|
|
50
50
|
/>
|
|
51
51
|
|
|
@@ -113,11 +113,7 @@ const form = ref({
|
|
|
113
113
|
token: '',
|
|
114
114
|
} as IRegistrateForm);
|
|
115
115
|
|
|
116
|
-
const
|
|
117
|
-
username: false,
|
|
118
|
-
email: false,
|
|
119
|
-
password_hash: false,
|
|
120
|
-
});
|
|
116
|
+
const { errors, clearError, resetErrors, validate, applyApiError } = useFormErrors(['email', 'username', 'password_hash'] as const);
|
|
121
117
|
|
|
122
118
|
const emit = defineEmits<{
|
|
123
119
|
(eventName: 'login'): void
|
|
@@ -129,7 +125,7 @@ function onLoginClick() {
|
|
|
129
125
|
|
|
130
126
|
const loading = ref(false);
|
|
131
127
|
|
|
132
|
-
const {
|
|
128
|
+
const { show, resetSnackbar } = useSnackbar();
|
|
133
129
|
// Загружаем reCAPTCHA при открытии формы (onMounted) — даёт Google время собрать данные о поведении
|
|
134
130
|
onMounted(async () => {
|
|
135
131
|
try {
|
|
@@ -142,23 +138,18 @@ onMounted(async () => {
|
|
|
142
138
|
function validateForm(): boolean {
|
|
143
139
|
const { username, email, password_hash } = form.value;
|
|
144
140
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
password_hash
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (!validateEmail(email)) showError('error_email');
|
|
152
|
-
if (!validatePassword(password_hash)) showError('error_password');
|
|
153
|
-
if (!validateUsername(username)) showError('error_username');
|
|
154
|
-
|
|
155
|
-
error.value = errors;
|
|
141
|
+
const results = [
|
|
142
|
+
validate('email', email, validateEmail, 'snackbar.error_email'),
|
|
143
|
+
validate('username', username, validateUsername, 'snackbar.error_username'),
|
|
144
|
+
validate('password_hash', password_hash, validatePassword, 'snackbar.error_password'),
|
|
145
|
+
];
|
|
156
146
|
|
|
157
|
-
return
|
|
147
|
+
return results.every(Boolean);
|
|
158
148
|
}
|
|
159
149
|
|
|
160
150
|
async function submit() {
|
|
161
151
|
resetSnackbar();
|
|
152
|
+
resetErrors();
|
|
162
153
|
if (!validateForm()) return;
|
|
163
154
|
|
|
164
155
|
try {
|
|
@@ -173,7 +164,7 @@ async function submit() {
|
|
|
173
164
|
useAuthPopup().closeAuthPopup();
|
|
174
165
|
show('Registration success');
|
|
175
166
|
} catch (err) {
|
|
176
|
-
|
|
167
|
+
applyApiError(err);
|
|
177
168
|
console.error('Ошибка reCAPTCHA или регистрации:', err);
|
|
178
169
|
} finally {
|
|
179
170
|
loading.value = false;
|
|
@@ -85,9 +85,9 @@
|
|
|
85
85
|
v-else
|
|
86
86
|
v-model="newPlaylistName"
|
|
87
87
|
v-model:type="playlistType"
|
|
88
|
-
:error="
|
|
88
|
+
:error="errors.name"
|
|
89
89
|
:placeholder="$t('playlist_name')"
|
|
90
|
-
@update:error="
|
|
90
|
+
@update:error="clearError('name')"
|
|
91
91
|
@close="clearNewPlaylist"
|
|
92
92
|
@submit="onSaveClick"
|
|
93
93
|
/>
|
|
@@ -199,9 +199,10 @@ async function closePopup() {
|
|
|
199
199
|
|
|
200
200
|
const newPlaylistName = ref('');
|
|
201
201
|
const playlistType = ref<PlaylistType>(PlaylistType.Public);
|
|
202
|
-
const
|
|
202
|
+
const { errors, clearError, resetErrors, validate, setError } = useFormErrors(['name'] as const);
|
|
203
203
|
|
|
204
204
|
function clearNewPlaylist() {
|
|
205
|
+
resetErrors();
|
|
205
206
|
newPlaylistName.value = '';
|
|
206
207
|
playlistType.value = PlaylistType.Public;
|
|
207
208
|
step.value = 'add';
|
|
@@ -223,7 +224,7 @@ const postVideoForm = computed(() => ({
|
|
|
223
224
|
duration: videoCard.value?.duration ?? 0,
|
|
224
225
|
}));
|
|
225
226
|
|
|
226
|
-
const { setErrorState, show
|
|
227
|
+
const { setErrorState, show } = useSnackbar();
|
|
227
228
|
const { generateLink } = useGenerateLink();
|
|
228
229
|
const { t } = useI18n();
|
|
229
230
|
|
|
@@ -259,15 +260,15 @@ async function onSaveClick() {
|
|
|
259
260
|
|
|
260
261
|
if (step.value === 'new') {
|
|
261
262
|
const name = newPlaylistName.value.trim();
|
|
262
|
-
|
|
263
|
+
resetErrors();
|
|
264
|
+
if (!validate('name', name)) return;
|
|
263
265
|
|
|
264
266
|
const nameExists = props.userPlaylists?.items?.some(
|
|
265
267
|
(item: IPlaylistCard) => item.name.toLowerCase() === name.toLowerCase()
|
|
266
268
|
);
|
|
267
269
|
|
|
268
270
|
if (nameExists) {
|
|
269
|
-
|
|
270
|
-
showError('playlist_name_exists');
|
|
271
|
+
setError('name', 'snackbar.playlist_name_exists');
|
|
271
272
|
return;
|
|
272
273
|
}
|
|
273
274
|
|
|
@@ -32,10 +32,10 @@ import type { PlaylistType } from '../../runtime';
|
|
|
32
32
|
const props = withDefaults(defineProps<{
|
|
33
33
|
modelValue: string
|
|
34
34
|
type: PlaylistType
|
|
35
|
-
error?: boolean
|
|
35
|
+
error?: string | boolean
|
|
36
36
|
placeholder?: string
|
|
37
37
|
}>(), {
|
|
38
|
-
error:
|
|
38
|
+
error: undefined,
|
|
39
39
|
placeholder: '',
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -69,6 +69,9 @@ const currentType = computed({
|
|
|
69
69
|
|
|
70
70
|
.playlist-input .ui-input {
|
|
71
71
|
--input-bg: #{$playlist-input-bg};
|
|
72
|
+
|
|
73
|
+
grid-row: 1 / span 2;
|
|
74
|
+
grid-template-rows: subgrid;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
.playlist-input .ui-input__input {
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
v-model="form.currentPassword"
|
|
7
7
|
:label="t('profile.old_password')"
|
|
8
8
|
:placeholder="t('profile.old_password')"
|
|
9
|
-
:error="
|
|
9
|
+
:error="errors.currentPassword"
|
|
10
10
|
type="password"
|
|
11
11
|
hide-label
|
|
12
12
|
autocomplete="new-password"
|
|
13
|
-
@update:error="
|
|
13
|
+
@update:error="clearError('currentPassword')"
|
|
14
14
|
/>
|
|
15
15
|
</div>
|
|
16
16
|
<div class="profile-password__new">
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
v-model="form.newPassword"
|
|
19
19
|
:label="t('profile.new_password')"
|
|
20
20
|
:placeholder="t('profile.new_password')"
|
|
21
|
-
:error="
|
|
21
|
+
:error="errors.newPassword"
|
|
22
22
|
type="password"
|
|
23
23
|
hide-label
|
|
24
24
|
autocomplete="new-password"
|
|
25
|
-
@update:error="
|
|
25
|
+
@update:error="clearError('newPassword')"
|
|
26
26
|
/>
|
|
27
27
|
</div>
|
|
28
28
|
<UiBtn
|
|
@@ -48,38 +48,29 @@ const form = ref<IChangePasswordForm>({
|
|
|
48
48
|
|
|
49
49
|
const { t } = useI18n();
|
|
50
50
|
|
|
51
|
-
const
|
|
52
|
-
currentPassword: false,
|
|
53
|
-
newPassword: false,
|
|
54
|
-
});
|
|
51
|
+
const { errors, clearError, resetErrors, validate, applyApiError } = useFormErrors(['currentPassword', 'newPassword'] as const);
|
|
55
52
|
|
|
56
53
|
const loading = ref(false);
|
|
57
54
|
|
|
58
|
-
function update() {
|
|
59
|
-
|
|
60
|
-
error.value.newPassword = false;
|
|
55
|
+
async function update() {
|
|
56
|
+
resetErrors();
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
const results = [
|
|
59
|
+
validate('currentPassword', form.value.currentPassword, validatePassword, 'snackbar.error_password'),
|
|
60
|
+
validate('newPassword', form.value.newPassword, validatePassword, 'snackbar.error_password'),
|
|
61
|
+
];
|
|
65
62
|
|
|
66
|
-
if (!
|
|
67
|
-
error.value.newPassword = true;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (error.value.currentPassword || error.value.newPassword) {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
63
|
+
if (!results.every(Boolean)) return;
|
|
73
64
|
|
|
74
65
|
try {
|
|
75
66
|
loading.value = true;
|
|
76
|
-
useUser(AuthorizationApiService).changePassword(form.value);
|
|
77
|
-
} catch(err) {
|
|
78
|
-
console.log(err, 'error password changed');
|
|
79
|
-
} finally {
|
|
80
|
-
loading.value = false;
|
|
67
|
+
await useUser(AuthorizationApiService).changePassword(form.value);
|
|
81
68
|
form.value.currentPassword = '';
|
|
82
69
|
form.value.newPassword = '';
|
|
70
|
+
} catch (err) {
|
|
71
|
+
applyApiError(err, 'error_while_changing_password');
|
|
72
|
+
} finally {
|
|
73
|
+
loading.value = false;
|
|
83
74
|
}
|
|
84
75
|
}
|
|
85
76
|
</script>
|
|
@@ -56,11 +56,11 @@
|
|
|
56
56
|
v-model="form.email"
|
|
57
57
|
:label="t('profile.email')"
|
|
58
58
|
:placeholder="t('enter_email')"
|
|
59
|
-
:error="
|
|
59
|
+
:error="errors.email"
|
|
60
60
|
type="email"
|
|
61
61
|
label-icon="mail"
|
|
62
62
|
class="profile-setup__email-edit"
|
|
63
|
-
@update:error="
|
|
63
|
+
@update:error="clearError('email')"
|
|
64
64
|
/>
|
|
65
65
|
<UiSelect
|
|
66
66
|
v-model="formCountry"
|
|
@@ -179,9 +179,7 @@ const memberSince = computed(() => {
|
|
|
179
179
|
return new Intl.DateTimeFormat(locale.value, { month: 'long', year: 'numeric' }).format(date);
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
-
const
|
|
183
|
-
email: false,
|
|
184
|
-
});
|
|
182
|
+
const { errors, clearError, resetErrors, validate, applyApiError } = useFormErrors(['email'] as const);
|
|
185
183
|
|
|
186
184
|
function setDate(string: string) {
|
|
187
185
|
form.value.birthday = toUnix(string);
|
|
@@ -264,12 +262,9 @@ const updateProfile = async () => {
|
|
|
264
262
|
form.value.country = findCountry() || null;
|
|
265
263
|
form.value.gender = findGender() || null;
|
|
266
264
|
|
|
267
|
-
|
|
265
|
+
resetErrors();
|
|
268
266
|
|
|
269
|
-
if (!
|
|
270
|
-
error.value.email = true;
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
267
|
+
if (!validate('email', form.value.email, validateEmail, 'snackbar.error_email')) return;
|
|
273
268
|
|
|
274
269
|
try {
|
|
275
270
|
loading.value = true;
|
|
@@ -284,7 +279,12 @@ const updateProfile = async () => {
|
|
|
284
279
|
const value = {
|
|
285
280
|
email: form.value.email
|
|
286
281
|
};
|
|
287
|
-
|
|
282
|
+
try {
|
|
283
|
+
await changeEmail(value);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
applyApiError(err, 'error_while_changing_email');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
await updateProfileUser(form.value);
|
|
@@ -100,16 +100,16 @@
|
|
|
100
100
|
:enterkeyhint="subItem.type === 'textarea' ? undefined : (isLastTextInput(subItem.value) ? 'send' : 'next')"
|
|
101
101
|
:placeholder="subItem.placeholder ? t(`report_form.${subItem.placeholder}`) : undefined"
|
|
102
102
|
:required="subItem.required"
|
|
103
|
-
:error="
|
|
104
|
-
@update:error="(
|
|
103
|
+
:error="errors[subItem.value]"
|
|
104
|
+
@update:error="clearError(subItem.value)"
|
|
105
105
|
@enter="onFieldEnter(subItem.value)"
|
|
106
106
|
/>
|
|
107
107
|
<UiCheckbox
|
|
108
108
|
v-if="subItem.type === 'checkbox'"
|
|
109
109
|
v-model="form.data[subItem.value] as boolean"
|
|
110
110
|
:required="subItem.required"
|
|
111
|
-
:error="
|
|
112
|
-
@update:error="(
|
|
111
|
+
:error="!!errors[subItem.value]"
|
|
112
|
+
@update:error="clearError(subItem.value)"
|
|
113
113
|
>{{ t(`report_form.${subItem.text}`) }}
|
|
114
114
|
</UiCheckbox>
|
|
115
115
|
<UiRadio
|
|
@@ -155,7 +155,6 @@ const { initRecaptcha, getRecaptchaToken } = useRecaptcha();
|
|
|
155
155
|
|
|
156
156
|
const reasonValue = ref('');
|
|
157
157
|
const errorRadio = ref(false);
|
|
158
|
-
const error = ref<Record<string, boolean>>({});
|
|
159
158
|
|
|
160
159
|
const { t } = useI18n();
|
|
161
160
|
|
|
@@ -222,6 +221,8 @@ const loading = ref(false);
|
|
|
222
221
|
|
|
223
222
|
const activeCategory = computed(() => reportFormsScheme.find((subItem) => subItem.subject === activeStep.value));
|
|
224
223
|
const requiredFields = computed(() => activeCategory.value?.items?.filter(item => item.required).map(item => item.value));
|
|
224
|
+
const fieldNames = computed(() => activeCategory.value?.items?.map(item => item.value).filter(Boolean) ?? []);
|
|
225
|
+
const { errors, clearError, resetErrors, validate } = useFormErrors(fieldNames);
|
|
225
226
|
const hasReasonHeading = computed(() => activeCategory.value?.items?.some(item => item.type === 'heading'));
|
|
226
227
|
|
|
227
228
|
// Навигация по Enter: однострочные поля (text/email/tel) — фокус на следующее, последнее — сабмит; textarea не участвует
|
|
@@ -257,7 +258,7 @@ function resetForm() {
|
|
|
257
258
|
}
|
|
258
259
|
});
|
|
259
260
|
reasonValue.value = '';
|
|
260
|
-
|
|
261
|
+
resetErrors();
|
|
261
262
|
errorRadio.value = false;
|
|
262
263
|
}
|
|
263
264
|
|
|
@@ -277,7 +278,7 @@ onMounted(async () => {
|
|
|
277
278
|
async function submit() {
|
|
278
279
|
resetSnackbar();
|
|
279
280
|
errorRadio.value = false;
|
|
280
|
-
|
|
281
|
+
resetErrors();
|
|
281
282
|
|
|
282
283
|
let valid = true;
|
|
283
284
|
|
|
@@ -286,26 +287,23 @@ async function submit() {
|
|
|
286
287
|
if (errorRadio.value) valid = false;
|
|
287
288
|
}
|
|
288
289
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
if (error.value[ item ]) valid = false;
|
|
307
|
-
});
|
|
308
|
-
}
|
|
290
|
+
requiredFields.value?.forEach((item) => {
|
|
291
|
+
const val = form.value.data[ item ];
|
|
292
|
+
|
|
293
|
+
let isValid: boolean;
|
|
294
|
+
|
|
295
|
+
if (item === 'from') {
|
|
296
|
+
isValid = validate(item, val, validateEmail, 'snackbar.error_email');
|
|
297
|
+
} else if (item === 'phone') {
|
|
298
|
+
isValid = validate(item, val, validatePhone, 'snackbar.error_phone');
|
|
299
|
+
} else if (typeof val === 'boolean' || ['confirmTrue', 'confirmOwner'].includes(item)) {
|
|
300
|
+
isValid = validate(item, val ? 'checked' : '');
|
|
301
|
+
} else {
|
|
302
|
+
isValid = validate(item, val);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!isValid) valid = false;
|
|
306
|
+
});
|
|
309
307
|
|
|
310
308
|
if (!valid) return;
|
|
311
309
|
|
package/components/ui/input.vue
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
:name="name"
|
|
46
46
|
:enterkeyhint="enterkeyhint"
|
|
47
47
|
:tabindex="disabled ? '-1' : '0'"
|
|
48
|
+
:aria-invalid="error ? 'true' : undefined"
|
|
49
|
+
:aria-describedby="errorText ? errorId : undefined"
|
|
48
50
|
class="ui-input__input"
|
|
49
51
|
rows="3"
|
|
50
52
|
spellcheck="false"
|
|
@@ -67,6 +69,8 @@
|
|
|
67
69
|
:autofocus="autofocus"
|
|
68
70
|
:tabindex="disabled ? '-1' : '0'"
|
|
69
71
|
:placeholder="placeholder"
|
|
72
|
+
:aria-invalid="error ? 'true' : undefined"
|
|
73
|
+
:aria-describedby="errorText ? errorId : undefined"
|
|
70
74
|
class="ui-input__input"
|
|
71
75
|
autocapitalize="off"
|
|
72
76
|
spellcheck="false"
|
|
@@ -88,16 +92,25 @@
|
|
|
88
92
|
/>
|
|
89
93
|
</button>
|
|
90
94
|
</div>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
<div
|
|
96
|
+
v-if="hasErrorText"
|
|
97
|
+
:aria-hidden="errorText ? undefined : 'true'"
|
|
98
|
+
class="ui-input__error"
|
|
99
|
+
>
|
|
100
|
+
<p
|
|
101
|
+
:id="errorId"
|
|
102
|
+
class="ui-input__error-text"
|
|
103
|
+
aria-live="polite"
|
|
104
|
+
>
|
|
105
|
+
<UiIcon
|
|
106
|
+
v-if="shownError"
|
|
107
|
+
name="exclamation-circle"
|
|
108
|
+
size="16"
|
|
109
|
+
class="ui-input__error-icon"
|
|
110
|
+
/>
|
|
111
|
+
{{ shownError }}
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
101
114
|
</div>
|
|
102
115
|
</template>
|
|
103
116
|
|
|
@@ -109,7 +122,7 @@ const props = withDefaults(defineProps<{
|
|
|
109
122
|
type?: InputTypes
|
|
110
123
|
inputmode?: 'text' | 'numeric' | 'email'
|
|
111
124
|
enterkeyhint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'
|
|
112
|
-
error?: boolean
|
|
125
|
+
error?: string | boolean
|
|
113
126
|
success?: string | boolean
|
|
114
127
|
size?: 'm' | 's'
|
|
115
128
|
autofocus?: boolean
|
|
@@ -140,6 +153,18 @@ const emit = defineEmits<{
|
|
|
140
153
|
|
|
141
154
|
const showText = ref(false);
|
|
142
155
|
|
|
156
|
+
const errorId = useId();
|
|
157
|
+
|
|
158
|
+
const hasErrorText = computed(() => typeof props.error === 'string');
|
|
159
|
+
|
|
160
|
+
const errorText = computed(() => hasErrorText.value ? props.error as string : '');
|
|
161
|
+
|
|
162
|
+
const shownError = ref(errorText.value);
|
|
163
|
+
|
|
164
|
+
watch(errorText, (text) => {
|
|
165
|
+
if (text) shownError.value = text;
|
|
166
|
+
});
|
|
167
|
+
|
|
143
168
|
const inputRef = ref<HTMLInputElement | HTMLTextAreaElement>();
|
|
144
169
|
|
|
145
170
|
defineExpose({ focus: () => inputRef.value?.focus() });
|
|
@@ -278,6 +303,34 @@ const normalizedValue = computed(() => {
|
|
|
278
303
|
border-color: var(--input-border--error, #{$input-border--error});
|
|
279
304
|
}
|
|
280
305
|
|
|
306
|
+
.ui-input__error {
|
|
307
|
+
display: grid;
|
|
308
|
+
grid-template-rows: 0fr;
|
|
309
|
+
opacity: 0;
|
|
310
|
+
transition: var(--transition) grid-template-rows, var(--transition) opacity;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.ui-input.ui-input--error .ui-input__error {
|
|
314
|
+
grid-template-rows: 1fr;
|
|
315
|
+
opacity: 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.ui-input__error-text {
|
|
319
|
+
@include font-xs;
|
|
320
|
+
|
|
321
|
+
display: flex;
|
|
322
|
+
column-gap: 4px;
|
|
323
|
+
align-items: flex-start;
|
|
324
|
+
min-height: 0;
|
|
325
|
+
overflow: hidden;
|
|
326
|
+
padding-top: 6px;
|
|
327
|
+
color: var(--input-error, #{$input-error});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.ui-input__error-icon {
|
|
331
|
+
flex-shrink: 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
281
334
|
.ui-input__button {
|
|
282
335
|
position: absolute;
|
|
283
336
|
top: 50%;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { convertString, parseApiFieldErrors } from '../runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Ошибки полей формы: валидация, пустое обязательное поле, ошибка бэка по конкретному полю — выводятся у поля
|
|
5
|
+
* (`<UiInput :error="errors.<field>">`). Снекбар остаётся только для ошибок формы целиком.
|
|
6
|
+
* Сообщение — i18n-ключ (`field_error.required`, `snackbar.error_email`) или текст бэка: его ищем в `snackbar.*`
|
|
7
|
+
* по snake_case, как снекбар, а не найдя — показываем как есть.
|
|
8
|
+
*/
|
|
9
|
+
export const useFormErrors = <TField extends string>(fields: MaybeRefOrGetter<readonly TField[]>) => {
|
|
10
|
+
const { t, te } = useI18n();
|
|
11
|
+
const { setErrorState, showError } = useSnackbar();
|
|
12
|
+
|
|
13
|
+
const raw = ref({}) as Ref<Partial<Record<TField, string>>>;
|
|
14
|
+
|
|
15
|
+
function translate(message: string) {
|
|
16
|
+
if (!message) return '';
|
|
17
|
+
if (te(message)) return t(message);
|
|
18
|
+
|
|
19
|
+
const snackKey = `snackbar.${convertString().toSnakeCase(message)}`;
|
|
20
|
+
return te(snackKey) ? t(snackKey) : message;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const errors = computed(() =>
|
|
24
|
+
Object.fromEntries(toValue(fields).map(field => [field, translate(raw.value[field] ?? '')])) as Record<TField, string>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const hasErrors = computed(() => toValue(fields).some(field => raw.value[field]));
|
|
28
|
+
|
|
29
|
+
function setError(field: TField, message: string) {
|
|
30
|
+
raw.value[field] = message;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clearError(field: TField) {
|
|
34
|
+
raw.value[field] = '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resetErrors() {
|
|
38
|
+
raw.value = {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Обязательное поле пустое → `field_error.required`, иначе `check` решает, валидно ли, и даёт `message`. */
|
|
42
|
+
function validate(field: TField, value: unknown, check?: (value: string) => boolean, message?: string) {
|
|
43
|
+
const text = String(value ?? '').trim();
|
|
44
|
+
|
|
45
|
+
if (!text) {
|
|
46
|
+
setError(field, 'field_error.required');
|
|
47
|
+
} else if (check && message && !check(text)) {
|
|
48
|
+
setError(field, message);
|
|
49
|
+
} else {
|
|
50
|
+
clearError(field);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return !raw.value[field];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ошибки бэка по известным полям — к полям; если ни одно не подошло — ошибка формы в снекбар:
|
|
58
|
+
* `fallback`, если передан, иначе текст бэка.
|
|
59
|
+
*/
|
|
60
|
+
function applyApiError(error: unknown, fallback?: string) {
|
|
61
|
+
const fieldErrors = parseApiFieldErrors(error);
|
|
62
|
+
const known = toValue(fields).filter(field => fieldErrors[field]);
|
|
63
|
+
|
|
64
|
+
if (!known.length) {
|
|
65
|
+
if (fallback) showError(fallback);
|
|
66
|
+
else setErrorState(error);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
known.forEach(field => setError(field, fieldErrors[field] as string));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
errors,
|
|
75
|
+
hasErrors,
|
|
76
|
+
setError,
|
|
77
|
+
clearError,
|
|
78
|
+
resetErrors,
|
|
79
|
+
validate,
|
|
80
|
+
applyApiError,
|
|
81
|
+
};
|
|
82
|
+
};
|
package/composables/use-user.ts
CHANGED
|
@@ -24,7 +24,7 @@ function omit<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K
|
|
|
24
24
|
* @param apiService - сервис для API-запросов
|
|
25
25
|
*/
|
|
26
26
|
export const useUser = (apiService) => {
|
|
27
|
-
const { show
|
|
27
|
+
const { show } = useSnackbar();
|
|
28
28
|
const { cookie: authCookie, setToken, clearToken } = useAuthCookie();
|
|
29
29
|
const register = async (form: IRegistrateForm) => {
|
|
30
30
|
const response = await apiService.register(form);
|
|
@@ -76,8 +76,8 @@ export const useUser = (apiService) => {
|
|
|
76
76
|
await apiService.changePassword(form);
|
|
77
77
|
show('Password changed');
|
|
78
78
|
} catch (err) {
|
|
79
|
-
showError('Error while changing password');
|
|
80
79
|
console.log(err, 'error change password');
|
|
80
|
+
throw err;
|
|
81
81
|
}
|
|
82
82
|
};
|
|
83
83
|
|
|
@@ -86,8 +86,8 @@ export const useUser = (apiService) => {
|
|
|
86
86
|
await apiService.changeEmail(form);
|
|
87
87
|
show('email_changed');
|
|
88
88
|
} catch (err) {
|
|
89
|
-
showError('Error while changing email');
|
|
90
89
|
console.log(err, 'error change email');
|
|
90
|
+
throw err;
|
|
91
91
|
}
|
|
92
92
|
};
|
|
93
93
|
|
|
@@ -98,8 +98,8 @@ export const useUser = (apiService) => {
|
|
|
98
98
|
show('Password recovered');
|
|
99
99
|
await useRouter().push(generateLink('/profile'));
|
|
100
100
|
} catch (err) {
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
console.log(err, 'error recovery password');
|
|
102
|
+
throw err;
|
|
103
103
|
}
|
|
104
104
|
};
|
|
105
105
|
|
package/i18n/locales/en-US.json
CHANGED
package/package.json
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
<UiInput
|
|
5
5
|
v-model="form.newPassword"
|
|
6
6
|
:label="t('profile.new_password')"
|
|
7
|
-
:error="
|
|
7
|
+
:error="errors.newPassword"
|
|
8
8
|
type="password"
|
|
9
9
|
autocomplete="new-password"
|
|
10
|
-
@update:error="
|
|
10
|
+
@update:error="clearError('newPassword')"
|
|
11
11
|
/>
|
|
12
12
|
<UiBtn
|
|
13
13
|
:disabled="form.newPassword.length < 4"
|
|
@@ -32,24 +32,18 @@ const form = ref<IRecoveryPasswordForm>({
|
|
|
32
32
|
token: String(route.query['token']),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
const
|
|
36
|
-
newPassword: false,
|
|
37
|
-
});
|
|
35
|
+
const { errors, clearError, resetErrors, validate, applyApiError } = useFormErrors(['newPassword'] as const);
|
|
38
36
|
|
|
39
|
-
function update() {
|
|
40
|
-
|
|
37
|
+
async function update() {
|
|
38
|
+
resetErrors();
|
|
41
39
|
|
|
42
|
-
if (!
|
|
43
|
-
error.value.newPassword = true;
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
40
|
+
if (!validate('newPassword', form.value.newPassword, validatePassword, 'snackbar.error_password')) return;
|
|
46
41
|
|
|
47
42
|
try {
|
|
48
|
-
useUser(AuthorizationApiService).recoveryPassword(form.value);
|
|
49
|
-
} catch (err) {
|
|
50
|
-
console.log(err, 'error password recovery');
|
|
51
|
-
} finally {
|
|
43
|
+
await useUser(AuthorizationApiService).recoveryPassword(form.value);
|
|
52
44
|
form.value.newPassword = '';
|
|
45
|
+
} catch (err) {
|
|
46
|
+
applyApiError(err, 'error_while_recovery_password');
|
|
53
47
|
}
|
|
54
48
|
}
|
|
55
49
|
|
package/runtime/index.ts
CHANGED
|
@@ -58,6 +58,7 @@ export * from './utils/converters/convert-query-categories';
|
|
|
58
58
|
export * from './utils/converters/convert-string-array-to-chips';
|
|
59
59
|
export * from './utils/server/api-helper';
|
|
60
60
|
export * from './utils/server/parse-api-error';
|
|
61
|
+
export * from './utils/server/parse-api-field-errors';
|
|
61
62
|
export * from './utils/server/get-url-with-proxied-params';
|
|
62
63
|
export * from './utils/server/server-api-helper';
|
|
63
64
|
export * from './utils/converters/convert-date-to-timestamp';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseApiFieldErrors } from '../parse-api-field-errors';
|
|
3
|
+
|
|
4
|
+
describe('parseApiFieldErrors', () => {
|
|
5
|
+
it('объект полей с массивами — первое сообщение', () => {
|
|
6
|
+
const error = { response: { _data: { error: { username: ['Taken', 'Too short'], email: ['Invalid'] } } } };
|
|
7
|
+
expect(parseApiFieldErrors(error)).toEqual({ username: 'Taken', email: 'Invalid' });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('объект полей со строками', () => {
|
|
11
|
+
const error = { response: { _data: { error: { password_hash: 'Too short' } } } };
|
|
12
|
+
expect(parseApiFieldErrors(error)).toEqual({ password_hash: 'Too short' });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('вложенный data.error и JSON-строка', () => {
|
|
16
|
+
const error = { response: { _data: '{"data":{"error":{"email":["Invalid"]}}}' } };
|
|
17
|
+
expect(parseApiFieldErrors(error)).toEqual({ email: 'Invalid' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('пустые значения пропускаются', () => {
|
|
21
|
+
const error = { response: { _data: { error: { email: [], name: '' } } } };
|
|
22
|
+
expect(parseApiFieldErrors(error)).toEqual({});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('строка — ошибка формы, не поля', () => {
|
|
26
|
+
expect(parseApiFieldErrors({ response: { _data: { error: 'Invalid credentials' } } })).toEqual({});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('массив — ошибка формы, не поля', () => {
|
|
30
|
+
expect(parseApiFieldErrors({ response: { _data: { error: ['Bad request'] } } })).toEqual({});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('битый JSON и пустые входы', () => {
|
|
34
|
+
expect(parseApiFieldErrors({ response: { _data: '{oops' } })).toEqual({});
|
|
35
|
+
expect(parseApiFieldErrors(null)).toEqual({});
|
|
36
|
+
expect(parseApiFieldErrors({})).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Достаёт из ошибки API ошибки конкретных полей: `{ error: { field: ['msg'] } }` или `{ error: { field: 'msg' } }`.
|
|
3
|
+
* Строка или массив в `error` — ошибка формы целиком, полей нет → пустой объект.
|
|
4
|
+
*/
|
|
5
|
+
export function parseApiFieldErrors(error: any): Record<string, string> {
|
|
6
|
+
try {
|
|
7
|
+
const raw = error?.response?._data;
|
|
8
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
9
|
+
const source = parsed?.data?.error || parsed?.error;
|
|
10
|
+
|
|
11
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
|
|
12
|
+
|
|
13
|
+
return Object.entries(source).reduce<Record<string, string>>((acc, [field, value]) => {
|
|
14
|
+
const message = Array.isArray(value) ? value[0] : value;
|
|
15
|
+
if (typeof message === 'string' && message) acc[field] = message;
|
|
16
|
+
return acc;
|
|
17
|
+
}, {});
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|