@akinon/pz-theme 2.0.165-beta.0 → 2.0.165

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.
@@ -11,6 +11,7 @@ import {
11
11
  AuthFieldErrors,
12
12
  AuthFieldErrorsProvider,
13
13
  clearAuthFieldError,
14
+ eventFieldName,
14
15
  collectAuthFieldErrors,
15
16
  focusFirstAuthFieldError
16
17
  } from '../auth/auth-field-errors';
@@ -413,11 +414,9 @@ const AuthFormBlock = ({
413
414
  );
414
415
 
415
416
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
416
- const target = event.target as { name?: string } | null;
417
+ const name = eventFieldName(event.target);
417
418
 
418
- setFieldErrors((current) =>
419
- clearAuthFieldError(current, target?.name || '')
420
- );
419
+ setFieldErrors((current) => clearAuthFieldError(current, name));
421
420
  };
422
421
 
423
422
  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -0,0 +1,206 @@
1
+ 'use client';
2
+
3
+ import React, { useState } from 'react';
4
+ import { partAttrs } from '../utils';
5
+
6
+ /**
7
+ * A birth date asked as three selects — day, month, year — instead of the
8
+ * browser's date control.
9
+ *
10
+ * The native control is the right default (its value is already the
11
+ * `YYYY-MM-DD` commerce stores, and it follows the shopper's locale), but it
12
+ * cannot be dressed: its popup, its separators and its empty "gg.aa.yyyy" mask
13
+ * belong to the browser. A design that draws "Gün / Ay / Yıl" as three boxes
14
+ * of the brand's own select styling had no way to get there.
15
+ *
16
+ * The three selects carry no `name` — they would post three stray keys — and
17
+ * one hidden input submits the composed date under the field's name, so the
18
+ * hosting form reads exactly what it read from the native control. Each select
19
+ * says which field it belongs to through `data-field-name`, which is how the
20
+ * form's validation files a missing part under that field.
21
+ */
22
+
23
+ export interface BirthDateSelectsProps {
24
+ /** The name the composed `YYYY-MM-DD` value submits under. */
25
+ name: string;
26
+ /** A stored date to start from; anything that is not `YYYY-MM-DD…` is none. */
27
+ defaultValue: string;
28
+ required: boolean;
29
+ /** The field's caption, prefixed to each select's accessible name. */
30
+ label: string;
31
+ dayPlaceholder: string;
32
+ monthPlaceholder: string;
33
+ yearPlaceholder: string;
34
+ /**
35
+ * Twelve captions for the month select, January first. Anything shorter is
36
+ * ignored and the months are numbered instead (01–12), which reads right in
37
+ * every language.
38
+ */
39
+ monthLabels: string[];
40
+ /** The youngest age offered: the latest year is this many years ago. */
41
+ minAge: number;
42
+ /** The oldest age offered: the earliest year is this many years ago. */
43
+ maxAge: number;
44
+ invalid: boolean;
45
+ controlStyle: React.CSSProperties;
46
+ disabled?: boolean;
47
+ }
48
+
49
+ const pad = (value: string) => value.padStart(2, '0');
50
+
51
+ /** Days in a month (1–12); 31 until the month is known, 29 for February until the year is. */
52
+ export const daysInMonth = (year: string, month: string): number => {
53
+ if (!month) return 31;
54
+
55
+ // Day 0 of the next month is the last day of this one; a leap year stands in
56
+ // until a year is picked, so 29 February stays choosable.
57
+ return new Date(Number(year) || 2000, Number(month), 0).getDate();
58
+ };
59
+
60
+ /** `YYYY-MM-DD` → its parts as the selects hold them (no zero padding). */
61
+ export const splitIsoDate = (
62
+ value: string
63
+ ): { day: string; month: string; year: string } => {
64
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value || '');
65
+
66
+ if (!match) return { day: '', month: '', year: '' };
67
+
68
+ return {
69
+ year: match[1],
70
+ month: String(Number(match[2])),
71
+ day: String(Number(match[3]))
72
+ };
73
+ };
74
+
75
+ /** The parts → `YYYY-MM-DD`, or '' while any part is missing. */
76
+ export const joinIsoDate = (day: string, month: string, year: string) =>
77
+ day && month && year ? `${year}-${pad(month)}-${pad(day)}` : '';
78
+
79
+ const BirthDateSelects = ({
80
+ name,
81
+ defaultValue,
82
+ required,
83
+ label,
84
+ dayPlaceholder,
85
+ monthPlaceholder,
86
+ yearPlaceholder,
87
+ monthLabels,
88
+ minAge,
89
+ maxAge,
90
+ invalid,
91
+ controlStyle,
92
+ disabled = false
93
+ }: BirthDateSelectsProps) => {
94
+ const [parts, setParts] = useState(() => splitIsoDate(defaultValue));
95
+
96
+ const latestYear = new Date().getFullYear() - Math.max(0, minAge);
97
+ const earliestYear =
98
+ new Date().getFullYear() - Math.max(Math.max(0, minAge), maxAge);
99
+ const years: string[] = [];
100
+
101
+ for (let year = latestYear; year >= earliestYear; year -= 1) {
102
+ years.push(String(year));
103
+ }
104
+
105
+ // A stored year outside the offered range is still the shopper's answer; it
106
+ // stays selectable rather than being silently dropped on the next save.
107
+ if (parts.year && !years.includes(parts.year)) {
108
+ years.push(parts.year);
109
+ years.sort((a, b) => Number(b) - Number(a));
110
+ }
111
+
112
+ const dayCount = daysInMonth(parts.year, parts.month);
113
+ const months =
114
+ monthLabels.length >= 12
115
+ ? monthLabels.slice(0, 12)
116
+ : Array.from({ length: 12 }, (_, index) => pad(String(index + 1)));
117
+
118
+ const update =
119
+ (key: 'day' | 'month' | 'year') =>
120
+ (event: React.ChangeEvent<HTMLSelectElement>) => {
121
+ const nextValue = event.target.value;
122
+
123
+ setParts((current) => {
124
+ const next = { ...current, [key]: nextValue };
125
+
126
+ // 31 April does not exist; a day the new month cannot hold is cleared
127
+ // rather than rolled into the next month.
128
+ if (Number(next.day) > daysInMonth(next.year, next.month)) {
129
+ next.day = '';
130
+ }
131
+
132
+ return next;
133
+ });
134
+ };
135
+
136
+ // A half-answered date is not a date: once any part is picked, the others
137
+ // are asked for too, even on an optional field.
138
+ const partRequired =
139
+ required || Boolean(parts.day || parts.month || parts.year);
140
+
141
+ const select = (
142
+ key: 'day' | 'month' | 'year',
143
+ placeholder: string,
144
+ options: { value: string; caption: string }[]
145
+ ) => (
146
+ <select
147
+ data-field-name={name}
148
+ data-date-part={key}
149
+ value={parts[key]}
150
+ onChange={update(key)}
151
+ required={partRequired}
152
+ disabled={disabled}
153
+ aria-label={`${label} ${placeholder}`.trim()}
154
+ aria-invalid={invalid ? true : undefined}
155
+ autoComplete={`bday-${key}`}
156
+ {...partAttrs('input', { disabled })}
157
+ style={{ ...controlStyle, flex: '1 1 0', minWidth: 0 }}
158
+ >
159
+ <option value="">{placeholder}</option>
160
+ {options.map((option) => (
161
+ <option key={option.value} value={option.value}>
162
+ {option.caption}
163
+ </option>
164
+ ))}
165
+ </select>
166
+ );
167
+
168
+ return (
169
+ <div
170
+ data-birth-date-selects=""
171
+ {...partAttrs('date-parts')}
172
+ style={{ display: 'flex', gap: '8px', width: '100%' }}
173
+ >
174
+ {select(
175
+ 'day',
176
+ dayPlaceholder,
177
+ Array.from({ length: dayCount }, (_, index) => ({
178
+ value: String(index + 1),
179
+ caption: pad(String(index + 1))
180
+ }))
181
+ )}
182
+ {select(
183
+ 'month',
184
+ monthPlaceholder,
185
+ months.map((caption, index) => ({
186
+ value: String(index + 1),
187
+ caption
188
+ }))
189
+ )}
190
+ {select(
191
+ 'year',
192
+ yearPlaceholder,
193
+ years.map((year) => ({ value: year, caption: year }))
194
+ )}
195
+ <input
196
+ type="hidden"
197
+ name={name}
198
+ value={joinIsoDate(parts.day, parts.month, parts.year)}
199
+ disabled={disabled}
200
+ readOnly
201
+ />
202
+ </div>
203
+ );
204
+ };
205
+
206
+ export default BirthDateSelects;
@@ -8,6 +8,7 @@ import {
8
8
  AuthFieldErrors,
9
9
  AuthFieldErrorsProvider,
10
10
  clearAuthFieldError,
11
+ eventFieldName,
11
12
  collectAuthFieldErrors,
12
13
  focusFirstAuthFieldError
13
14
  } from '../auth/auth-field-errors';
@@ -224,11 +225,9 @@ const ForgotPasswordBlock = ({
224
225
  }, [formHtmlId, isLoading]);
225
226
 
226
227
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
227
- const target = event.target as { name?: string } | null;
228
+ const name = eventFieldName(event.target);
228
229
 
229
- setFieldErrors((current) =>
230
- clearAuthFieldError(current, target?.name || '')
231
- );
230
+ setFieldErrors((current) => clearAuthFieldError(current, name));
232
231
  };
233
232
 
234
233
  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
@@ -24,6 +24,7 @@ import {
24
24
  AuthFieldErrors,
25
25
  AuthFieldErrorsProvider,
26
26
  clearAuthFieldError,
27
+ eventFieldName,
27
28
  collectAuthFieldErrors
28
29
  } from '../auth/auth-field-errors';
29
30
  import {
@@ -424,11 +425,9 @@ const OtpFormBlock = ({
424
425
  * `change`.
425
426
  */
426
427
  const handleInput = (event: React.FormEvent<HTMLFormElement>) => {
427
- const target = event.target as { name?: string } | null;
428
+ const name = eventFieldName(event.target);
428
429
 
429
- setFieldErrors((current) =>
430
- clearAuthFieldError(current, target?.name || '')
431
- );
430
+ setFieldErrors((current) => clearAuthFieldError(current, name));
432
431
  };
433
432
 
434
433
  const submitPhone = async (event: React.FormEvent<HTMLFormElement>) => {