@7365admin1/layer-common 4.2.2-staging.267 → 4.2.2-staging.269

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.
@@ -1,42 +1,54 @@
1
1
  <template>
2
- <div class="d-flex flex-column">
3
- <v-text-field
4
- v-bind="$attrs"
5
- class="filter-field"
6
- ref="dateTimePickerRef"
7
- :model-value="dateTimeFormattedReadOnly"
8
- autocomplete="off"
9
- :placeholder="inputPlaceholder"
10
- :rules="rules"
11
- style="z-index: 10"
12
- @click="openDatePicker"
13
- >
14
- <template #append-inner>
15
- <v-icon icon="mdi-calendar" @click.stop="openDatePicker" />
16
- </template>
17
- </v-text-field>
18
- <div class="w-100 d-flex align-end ga-3 hidden-input">
19
- <input
20
- ref="dateInput"
21
- :type="inputType"
22
- v-model="dateTime"
23
- :min="minValue"
24
- />
25
- </div>
26
- </div>
2
+ <v-text-field
3
+ v-bind="$attrs"
4
+ ref="dateTimePickerRef"
5
+ class="filter-field"
6
+ v-model="fieldValue"
7
+ :type="inputType"
8
+ :label="placeholder || undefined"
9
+ :min="minValue"
10
+ :rules="rules"
11
+ persistent-placeholder
12
+ autocomplete="off"
13
+ />
27
14
  </template>
28
15
 
29
16
  <script setup lang="ts">
30
- import { ref, computed, watch } from "vue";
17
+ import { ref, computed } from "vue";
18
+
19
+ /*
20
+ * The field the user edits IS the native date input now.
21
+ *
22
+ * It used to be two elements: a read-only Vuetify text field for show, and a
23
+ * zero-height `opacity: 0` native input behind it that `showPicker()` was
24
+ * called on. That arrangement broke every way a user can enter a date:
25
+ *
26
+ * - TYPING went into Vuetify's own internal copy of the value (the text field
27
+ * was bound with `:model-value` and nothing listened for the update), so the
28
+ * typed date DISPLAYED and PASSED the required-rule while both models here
29
+ * stayed empty - `startDate: ""` went to the API, and nothing could ever be
30
+ * Upcoming.
31
+ * - PICKING a date drove the invisible input. A partly-filled
32
+ * `datetime-local` has an empty value until every segment is set, and the
33
+ * segments were invisible, so the picker closed and set nothing.
34
+ * - An End Date that was typed stayed invalid for the same reason as the
35
+ * first point, so an announcement with a real end date could not be created
36
+ * at all.
37
+ *
38
+ * One visible native input has the browser's own picker, its own typing, and a
39
+ * real `v-model`, which is all three fixes.
40
+ */
31
41
 
32
42
  const prop = defineProps({
33
43
  rules: {
34
44
  type: Array as PropType<Array<any>>,
35
45
  default: () => [],
36
46
  },
47
+ // Rendered as the field's label: a native date input always draws its own
48
+ // dd/mm/yyyy segments, so a placeholder would never be seen.
37
49
  placeholder: {
38
50
  type: String,
39
- default: "DD/MM/YYYY, HH:MM AM/PM",
51
+ default: "",
40
52
  },
41
53
  dateOnly: {
42
54
  type: Boolean,
@@ -48,11 +60,10 @@ const prop = defineProps({
48
60
  },
49
61
  });
50
62
 
51
- const { formatDateISO8601 } = useUtils();
52
- const dateTime = defineModel<string | null>({ default: null }); //2025-10-10T13:09 format
53
- const dateTimeUTC = defineModel<string | null>("utc", { default: null }); // UTC format
63
+ const dateTime = defineModel<string | null>({ default: null }); // 2025-10-10T13:09, local
64
+ const dateTimeUTC = defineModel<string | null>("utc", { default: null }); // ISO 8601, UTC
54
65
 
55
- const dateTimeFormattedReadOnly = ref<string | null>(null);
66
+ const dateTimePickerRef = ref<HTMLInputElement | null>(null);
56
67
  const inputType = computed(() => (prop.dateOnly ? "date" : "datetime-local"));
57
68
 
58
69
  const minValue = computed(() => {
@@ -62,148 +73,60 @@ const minValue = computed(() => {
62
73
  }
63
74
  return prop.min;
64
75
  });
65
- const inputPlaceholder = computed(
66
- () =>
67
- prop.placeholder ||
68
- (prop.dateOnly ? "MM/DD/YYYY" : "DD/MM/YYYY, HH:MM AM/PM")
69
- );
70
-
71
- const dateInput = ref<HTMLInputElement | null>(null);
72
- const dateTimePickerRef = ref<HTMLInputElement | null>(null);
73
- let nativeInput: HTMLInputElement | null = null;
74
-
75
- const isInitialLoad = ref(true);
76
-
77
- function handleNativeInputClick(e: MouseEvent) {
78
- e.stopPropagation();
79
- openDatePicker();
80
- }
81
-
82
- function openDatePicker() {
83
- setTimeout(() => {
84
- dateInput.value?.showPicker?.();
85
- }, 0);
86
- }
87
76
 
88
77
  function validate() {
89
78
  (dateTimePickerRef.value as any)?.validate();
90
79
  }
91
80
 
92
- function convertToReadableFormat(dateStr: string): string {
93
- if (!dateStr) return "";
81
+ const pad = (n: number) => String(n).padStart(2, "0");
94
82
 
95
- const date = new Date(dateStr);
96
- if (Number.isNaN(date.getTime())) return "";
97
-
98
- const month = String(date.getMonth() + 1).padStart(2, "0");
99
- const day = String(date.getDate()).padStart(2, "0");
100
- const year = date.getFullYear();
101
-
102
- if (prop.dateOnly) {
103
- return `${day}/${month}/${year}`;
83
+ // What the native input accepts: `YYYY-MM-DD` or `YYYY-MM-DDTHH:mm`, local time.
84
+ function toInputValue(value: string | null): string {
85
+ if (!value) return "";
86
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
87
+ return prop.dateOnly ? value : `${value}T00:00`;
104
88
  }
105
-
106
- let hours = date.getHours();
107
- const minutes = String(date.getMinutes()).padStart(2, "0");
108
-
109
- const ampm = hours >= 12 ? "PM" : "AM";
110
- hours = hours % 12;
111
- hours = hours ? hours : 12;
112
-
113
- const formattedTime = `${String(hours).padStart(2, "0")}:${minutes} ${ampm}`;
114
-
115
- return `${day}/${month}/${year}, ${formattedTime}`;
116
- }
117
-
118
- function toDateOnlyInputValue(dateStr: string): string {
119
- if (!dateStr) return "";
120
- if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr;
121
- const date = new Date(dateStr);
89
+ const date = new Date(value);
122
90
  if (Number.isNaN(date.getTime())) return "";
123
- const year = date.getFullYear();
124
- const month = String(date.getMonth() + 1).padStart(2, "0");
125
- const day = String(date.getDate()).padStart(2, "0");
126
- return `${year}-${month}-${day}`;
91
+ const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
92
+ return prop.dateOnly
93
+ ? day
94
+ : `${day}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
127
95
  }
128
96
 
129
- function handleInitialDate() {
130
- const dateDefault = dateTime.value;
131
- const dateUTC = dateTimeUTC.value;
132
- if (dateDefault) {
133
- dateTimeFormattedReadOnly.value = convertToReadableFormat(dateDefault);
134
- if (prop.dateOnly) {
135
- dateTimeUTC.value = toDateOnlyInputValue(dateDefault);
136
- } else {
137
- const localDate = new Date(dateDefault);
138
- dateTimeUTC.value = localDate.toISOString();
139
- }
140
- } else if (dateUTC) {
141
- dateTimeFormattedReadOnly.value = convertToReadableFormat(dateUTC);
142
- if (prop.dateOnly) {
143
- dateTime.value = toDateOnlyInputValue(dateUTC);
144
- } else {
145
- const localDate = new Date(dateUTC);
146
- dateTime.value = formatDateISO8601(localDate);
147
- }
148
- } else {
149
- dateTimeFormattedReadOnly.value = null;
150
- }
97
+ // What the API stores: a date-only field stays `YYYY-MM-DD`, everything else
98
+ // becomes UTC.
99
+ function toUTCValue(value: string | null): string | null {
100
+ if (!value) return null;
101
+ if (prop.dateOnly) return toInputValue(value).slice(0, 10) || null;
102
+ const date = new Date(value);
103
+ if (Number.isNaN(date.getTime())) return null;
104
+ return date.toISOString();
151
105
  }
152
106
 
153
- watch(
154
- dateTime,
155
- (dateVal) => {
156
- if (isInitialLoad.value) return; // ignore the first run
157
- if (!dateVal) {
158
- dateTimeFormattedReadOnly.value = null;
159
- dateTimeUTC.value = null;
160
- return;
161
- }
162
-
163
- dateTimeFormattedReadOnly.value = convertToReadableFormat(dateVal);
164
- if (prop.dateOnly) {
165
- dateTimeUTC.value = toDateOnlyInputValue(dateVal);
166
- } else {
167
- const localDate = new Date(dateVal);
168
- dateTimeUTC.value = localDate.toISOString();
107
+ // The one value the field is bound to. A getter and a setter instead of a pair
108
+ // of watchers: no initial-load flag, no chance of the two models fighting each
109
+ // other, and it behaves the same on the server as in the browser.
110
+ const fieldValue = computed({
111
+ get: () => {
112
+ // The two models only agree when the setter below wrote them together. When
113
+ // a PARENT writes one of them - the season pass dialog's subscription
114
+ // buttons write the UTC one - the other still holds this component's copy
115
+ // of whatever was in the field before, and reading that copy first would
116
+ // leave the field showing a date the form is no longer holding.
117
+ // Disagreement means the parent wrote, so the parent wins.
118
+ if (dateTimeUTC.value && toUTCValue(dateTime.value) !== dateTimeUTC.value) {
119
+ return toInputValue(dateTimeUTC.value);
169
120
  }
121
+ return toInputValue(dateTime.value || dateTimeUTC.value);
170
122
  },
171
- { immediate: false }
172
- );
173
-
174
- watch(
175
- dateTimeUTC,
176
- () => {
177
- handleInitialDate();
123
+ set: (value: string | null) => {
124
+ dateTime.value = value || null;
125
+ dateTimeUTC.value = toUTCValue(value);
178
126
  },
179
- { immediate: true }
180
- );
181
-
182
- onMounted(async () => {
183
- await nextTick();
184
- isInitialLoad.value = false;
185
- // Wait until Vuetify renders its internal input
186
- nativeInput = (dateTimePickerRef.value as any)?.$el?.querySelector(
187
- "input"
188
- );
189
- if (nativeInput) {
190
- nativeInput.addEventListener("click", handleNativeInputClick);
191
- }
192
- });
193
-
194
- onBeforeUnmount(() => {
195
- nativeInput?.removeEventListener("click", handleNativeInputClick);
196
- nativeInput = null;
197
127
  });
198
128
 
199
129
  defineExpose({
200
130
  validate,
201
131
  });
202
132
  </script>
203
-
204
- <style scoped>
205
- .hidden-input {
206
- opacity: 0;
207
- height: 0;
208
- }
209
- </style>
@@ -268,7 +268,7 @@
268
268
  />
269
269
  <InputDateTimePicker
270
270
  ref="startDateRef"
271
- v-model="vehicle.start"
271
+ v-model:utc="vehicle.start"
272
272
  :rules="[validStartDateRule]"
273
273
  />
274
274
  </v-col>
@@ -281,7 +281,7 @@
281
281
  />
282
282
  <InputDateTimePicker
283
283
  ref="expiryDateRef"
284
- v-model="vehicle.end"
284
+ v-model:utc="vehicle.end"
285
285
  :rules="[validExpiryDateRule]"
286
286
  />
287
287
  </v-col>
@@ -439,7 +439,7 @@ const prop = defineProps({
439
439
  },
440
440
  });
441
441
 
442
- const { requiredRule, formatDateISO8601, debounce } = useUtils();
442
+ const { requiredRule, debounce } = useUtils();
443
443
  const {
444
444
  addVehicle,
445
445
  getCustomSeasonPassTypes,
@@ -881,15 +881,20 @@ async function submit() {
881
881
 
882
882
  // }
883
883
 
884
+ /*
885
+ * The subscription buttons write the same thing the picker writes: a real
886
+ * instant.
887
+ *
888
+ * `formatDateISO8601` returns `YYYY-MM-DDTHH:mm` with no zone - the browser's
889
+ * WALL CLOCK, not a moment in time. Sent to the API as-is, the server parsed it
890
+ * in its own zone (UTC) and a pass entered as 05/12/2026 6:45 pm came back as
891
+ * 06/12/2026 2:45 am. These two fields now carry UTC end to end, so the preset
892
+ * and the picker cannot disagree about what "now" meant.
893
+ */
884
894
  watch(selectedSubscriptionDuration, (duration) => {
885
- const dateNowISO = formatDateISO8601(new Date());
886
- const weeksLaterISO = (week: number) => {
887
- const now = new Date();
888
- const dateWeekLater = new Date(
889
- now.getTime() + week * 7 * 24 * 60 * 60 * 1000
890
- );
891
- return formatDateISO8601(dateWeekLater);
892
- };
895
+ const dateNowISO = new Date().toISOString();
896
+ const weeksLaterISO = (week: number) =>
897
+ new Date(Date.now() + week * 7 * 24 * 60 * 60 * 1000).toISOString();
893
898
 
894
899
  if (duration === "custom") {
895
900
  vehicle.start = "";
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.2.2-staging.267",
5
+ "version": "4.2.2-staging.269",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",