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 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="error.username"
13
+ :error="errors.username"
14
14
  name="login-name"
15
15
  enterkeyhint="next"
16
- @update:error="(val: boolean) => (error.username = val)"
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="error.password_hash"
25
+ :error="errors.password_hash"
26
+ name="login-password"
26
27
  type="password"
27
28
  enterkeyhint="go"
28
- @update:error="(val: boolean) => (error.password_hash = val)"
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 error = ref({
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 { setErrorState, showError, show, resetSnackbar } = useSnackbar();
95
+ const { show, resetSnackbar } = useSnackbar();
98
96
 
99
97
  async function login() {
100
98
  resetSnackbar();
101
- error.value.username = false;
102
- error.value.password_hash = false;
103
-
104
- if (!validateUsername(form.value.username)) {
105
- error.value.username = true;
106
- showError('error_username');
107
- }
108
- if (!validatePassword(form.value.password_hash)) {
109
- error.value.password_hash = true;
110
- showError('error_password');
111
- }
112
-
113
- if (!error.value.username && !error.value.password_hash) {
114
- try {
115
- loading.value = true;
116
- await useUser(AuthorizationApiService).login(form.value);
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="error.email"
18
+ :error="errors.email"
19
19
  type="email"
20
20
  inputmode="email"
21
21
  enterkeyhint="send"
22
22
  autocomplete="email"
23
- @update:error="(val: boolean) => error.email = val"
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 error = ref({
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 { setErrorState, showError, show, resetSnackbar } = useSnackbar();
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
- error.value.email = false;
84
+ resetErrors();
87
85
 
88
- if (!validateEmail(form.value.email)) {
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
- setErrorState(error);
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="error.email"
18
+ :error="errors.email"
19
19
  type="email"
20
20
  inputmode="email"
21
21
  enterkeyhint="next"
22
22
  autocomplete="email"
23
- @update:error="(val: boolean) => error.email = val"
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="error.username"
32
+ :error="errors.username"
33
33
  name="login-name"
34
34
  enterkeyhint="next"
35
- @update:error="(val: boolean) => error.username = val"
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="error.password_hash"
44
+ :error="errors.password_hash"
45
45
  type="password"
46
46
  enterkeyhint="send"
47
47
  autocomplete="new-password"
48
- @update:error="(val: boolean) => error.password_hash = val"
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 error = ref({
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 { setErrorState, showError, show, resetSnackbar } = useSnackbar();
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 errors = {
146
- username: !validateUsername(username),
147
- email: !validateEmail(email),
148
- password_hash: !validatePassword(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 !Object.values(errors).some(Boolean);
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
- setErrorState(err);
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="nameError"
88
+ :error="errors.name"
89
89
  :placeholder="$t('playlist_name')"
90
- @update:error="nameError = $event"
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 nameError = ref(false);
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, showError } = useSnackbar();
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
- if (!name) return;
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
- nameError.value = true;
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: false,
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="error.currentPassword"
9
+ :error="errors.currentPassword"
10
10
  type="password"
11
11
  hide-label
12
12
  autocomplete="new-password"
13
- @update:error="val => error.currentPassword = val"
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="error.newPassword"
21
+ :error="errors.newPassword"
22
22
  type="password"
23
23
  hide-label
24
24
  autocomplete="new-password"
25
- @update:error="val => error.newPassword = val"
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 error = ref({
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
- error.value.currentPassword = false;
60
- error.value.newPassword = false;
55
+ async function update() {
56
+ resetErrors();
61
57
 
62
- if (!validatePassword(form.value.currentPassword)) {
63
- error.value.currentPassword = true;
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 (!validatePassword(form.value.newPassword)) {
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="error.email"
59
+ :error="errors.email"
60
60
  type="email"
61
61
  label-icon="mail"
62
62
  class="profile-setup__email-edit"
63
- @update:error="val => error.email = val"
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 error = ref({
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
- error.value.email = false;
265
+ resetErrors();
268
266
 
269
- if (!validateEmail(form.value.email)) {
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
- await changeEmail(value);
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="error[subItem.value]"
104
- @update:error="(val: boolean) => error[subItem.value] = val"
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="error[subItem.value]"
112
- @update:error="(val: boolean) => error[subItem.value] = val"
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
- Object.keys(error.value).forEach(key => error.value[ key ] = false);
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
- Object.keys(error.value).forEach(key => error.value[ key ] = false);
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
- if (requiredFields.value && requiredFields.value.length > 0) {
290
- requiredFields.value.forEach(item => {
291
- const val = form.value.data[ item ];
292
-
293
- if (item === 'from' && !validateEmail(val as string)) {
294
- error.value[ item ] = true;
295
- showError('error_email');
296
- } else if (item === 'phone' && !validatePhone(val as string)) {
297
- error.value[ item ] = !validatePhone(val as string);
298
- showError('error_phone');
299
- } else if (['confirmTrue', 'confirmOwner'].includes(item)) {
300
- error.value[ item ] = !val;
301
- } else {
302
- error.value[ item ] = val === undefined || val === null || String(val).trim() === '';
303
- if (error.value[ item ]) showError('error_text');
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
 
@@ -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
- <!-- <p-->
92
- <!-- v-if="error"-->
93
- <!-- class="ui-input__error"-->
94
- <!-- >-->
95
- <!-- <UiIcon-->
96
- <!-- name="danger"-->
97
- <!-- size="16"-->
98
- <!-- />-->
99
- <!-- {{ error }}-->
100
- <!-- </p>-->
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
+ };
@@ -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, showError } = useSnackbar();
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
- showError('Error while recovery password');
102
- console.log(err, 'error change password');
101
+ console.log(err, 'error recovery password');
102
+ throw err;
103
103
  }
104
104
  };
105
105
 
@@ -1,4 +1,7 @@
1
1
  {
2
+ "field_error": {
3
+ "required": "This field is required"
4
+ },
2
5
  "scenes": "Scenes",
3
6
  "register_to_upload": "Please sign up or log in to upload your videos",
4
7
  "age_gate": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1075",
4
+ "version": "0.0.1076",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -4,10 +4,10 @@
4
4
  <UiInput
5
5
  v-model="form.newPassword"
6
6
  :label="t('profile.new_password')"
7
- :error="error.newPassword"
7
+ :error="errors.newPassword"
8
8
  type="password"
9
9
  autocomplete="new-password"
10
- @update:error="val => error.newPassword = val"
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 error = ref({
36
- newPassword: false,
37
- });
35
+ const { errors, clearError, resetErrors, validate, applyApiError } = useFormErrors(['newPassword'] as const);
38
36
 
39
- function update() {
40
- error.value.newPassword = false;
37
+ async function update() {
38
+ resetErrors();
41
39
 
42
- if (!validatePassword(form.value.newPassword)) {
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
+ }