@nexxtmove/ui 0.1.23 → 0.1.25

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,6 +1,7 @@
1
1
  <script lang="ts" setup>
2
2
  import { computed, ref, onMounted } from 'vue'
3
- import { isSameDay, isSameMonth, isToday, format } from 'date-fns'
3
+ import { isSameDay, isSameMonth, format } from 'date-fns'
4
+ import { toZonedTime } from 'date-fns-tz'
4
5
  import type { Locale } from 'date-fns'
5
6
 
6
7
  interface CalendarDayViewProps {
@@ -10,6 +11,7 @@ interface CalendarDayViewProps {
10
11
  markedDates: Date[]
11
12
  locale: Locale
12
13
  isDisabled: (day: Date) => boolean
14
+ timezone: string
13
15
  }
14
16
 
15
17
  const props = defineProps<CalendarDayViewProps>()
@@ -19,6 +21,12 @@ const emit = defineEmits<{
19
21
 
20
22
  const dayButtonsRef = ref<HTMLElement[]>([])
21
23
 
24
+ const isTodayByTimezone = (day: Date): boolean => {
25
+ const zonedDay = toZonedTime(day, props.timezone)
26
+ const nowZoned = toZonedTime(new Date(), props.timezone)
27
+ return isSameDay(zonedDay, nowZoned)
28
+ }
29
+
22
30
  const isSelected = (day: Date): boolean => {
23
31
  return props.modelValue ? isSameDay(day, props.modelValue) : false
24
32
  }
@@ -61,10 +69,12 @@ const handleKeydown = (event: KeyboardEvent, dayIndex: number) => {
61
69
  nextIdx -= 7
62
70
  break
63
71
  case 'Enter':
64
- case ' ':
72
+ case ' ': {
65
73
  event.preventDefault()
66
- emit('select', props.calendarDays[dayIndex])
74
+ const selectedDay = props.calendarDays[dayIndex]
75
+ if (selectedDay) emit('select', selectedDay)
67
76
  return
77
+ }
68
78
  default:
69
79
  return
70
80
  }
@@ -81,7 +91,8 @@ const handleKeydown = (event: KeyboardEvent, dayIndex: number) => {
81
91
  onMounted(() => {
82
92
  const focusIdx = props.calendarDays.findIndex(
83
93
  (d) =>
84
- (props.modelValue && isSameDay(d, props.modelValue)) || (!props.modelValue && isToday(d)),
94
+ (props.modelValue && isSameDay(d, props.modelValue)) ||
95
+ (!props.modelValue && isTodayByTimezone(d)),
85
96
  )
86
97
  if (focusIdx !== -1) {
87
98
  // We don't auto-focus on mount to avoid stealing focus from the popover,
@@ -129,10 +140,12 @@ defineExpose({
129
140
  'relative flex h-8 w-8 items-center justify-center rounded-xl text-xs transition-colors duration-150 outline-none focus:ring-2 focus:ring-cornflower-blue-500 focus:ring-offset-1',
130
141
  !isSameMonth(day, viewDate) ? 'text-gray-300' : 'text-gray-900',
131
142
  isSelected(day) ? 'bg-cornflower-blue-500 font-medium text-white!' : '',
132
- isToday(day) && !isSelected(day)
143
+ isTodayByTimezone(day) && !isSelected(day)
133
144
  ? 'font-medium text-cornflower-blue-500! ring-1 ring-cornflower-blue-500'
134
145
  : '',
135
- isToday(day) && isSelected(day) ? 'ring-2 ring-cornflower-blue-500 ring-offset-2' : '',
146
+ isTodayByTimezone(day) && isSelected(day)
147
+ ? 'ring-2 ring-cornflower-blue-500 ring-offset-2'
148
+ : '',
136
149
  !isDisabled(day) && !isSelected(day)
137
150
  ? 'cursor-pointer hover:bg-cornflower-blue-50'
138
151
  : '',
@@ -6,63 +6,47 @@ import { nl } from 'date-fns/locale'
6
6
  const VIEW_YEAR = 2026
7
7
  const CURRENT_MONTH = 2 // March
8
8
 
9
+ const mountMonthView = (props: Record<string, unknown>) =>
10
+ mount(CalendarMonthView, {
11
+ props: {
12
+ viewYear: VIEW_YEAR,
13
+ modelValue: null,
14
+ currentMonth: CURRENT_MONTH,
15
+ locale: nl,
16
+ timezone: 'UTC',
17
+ ...(props as Record<string, unknown>),
18
+ },
19
+ })
20
+
9
21
  describe('CalendarMonthView', () => {
10
22
  it('renders all 12 months', () => {
11
- const wrapper = mount(CalendarMonthView, {
12
- props: {
13
- viewYear: VIEW_YEAR,
14
- modelValue: null,
15
- currentMonth: CURRENT_MONTH,
16
- locale: nl,
17
- },
18
- })
23
+ const wrapper = mountMonthView({})
19
24
  const buttons = wrapper.findAll('button')
20
25
  expect(buttons.length).toBe(12)
21
26
  })
22
27
 
23
28
  it('highlights the selected month', () => {
24
- const selectedDate = new Date(VIEW_YEAR, 5, 15) // June
25
- const wrapper = mount(CalendarMonthView, {
26
- props: {
27
- viewYear: VIEW_YEAR,
28
- modelValue: selectedDate,
29
- currentMonth: CURRENT_MONTH,
30
- locale: nl,
31
- },
32
- })
29
+ const selectedDate = new Date(Date.UTC(VIEW_YEAR, 5, 15)) // June
30
+ const wrapper = mountMonthView({ modelValue: selectedDate })
33
31
  const selectedButton = wrapper.find('button[aria-selected="true"]')
34
32
  expect(selectedButton.text()).toBe('Jun')
35
33
  })
36
34
 
37
35
  it('disables months based on minDate and maxDate', () => {
38
- const minDate = new Date(VIEW_YEAR, 2, 1) // March 1st
39
- const wrapper = mount(CalendarMonthView, {
40
- props: {
41
- viewYear: VIEW_YEAR,
42
- modelValue: null,
43
- currentMonth: CURRENT_MONTH,
44
- locale: nl,
45
- minDate,
46
- },
47
- })
36
+ const minDate = new Date(Date.UTC(VIEW_YEAR, 2, 1)) // March 1st
37
+ const wrapper = mountMonthView({ minDate })
48
38
  // January (0) and February (1) should be disabled
49
39
  const disabledButtons = wrapper.findAll('button[disabled]')
50
40
  expect(disabledButtons.length).toBe(2)
51
- expect(disabledButtons[0].text()).toBe('Jan')
52
- expect(disabledButtons[1].text()).toBe('Feb')
41
+ expect(disabledButtons[0]?.text()).toBe('Jan')
42
+ expect(disabledButtons[1]?.text()).toBe('Feb')
53
43
  })
54
44
 
55
45
  it('emits select event when a month is clicked', async () => {
56
- const wrapper = mount(CalendarMonthView, {
57
- props: {
58
- viewYear: VIEW_YEAR,
59
- modelValue: null,
60
- currentMonth: CURRENT_MONTH,
61
- locale: nl,
62
- },
63
- })
64
- await wrapper.findAll('button')[5].trigger('click')
65
- expect(wrapper.emitted('select')).toBeTruthy()
66
- expect(wrapper.emitted('select')![0][0]).toBe(5)
46
+ const wrapper = mountMonthView({})
47
+ await wrapper.findAll('button')[5]?.trigger('click')
48
+ const emitted = wrapper.emitted('select')
49
+ expect(emitted).toBeTruthy()
50
+ expect(emitted?.[0]?.[0]).toBe(5)
67
51
  })
68
52
  })
@@ -1,6 +1,7 @@
1
1
  <script lang="ts" setup>
2
2
  import { computed } from 'vue'
3
3
  import { format, startOfMonth, endOfMonth, startOfDay, endOfDay } from 'date-fns'
4
+ import { toZonedTime } from 'date-fns-tz'
4
5
  import type { Locale } from 'date-fns'
5
6
 
6
7
  interface CalendarMonthViewProps {
@@ -10,6 +11,7 @@ interface CalendarMonthViewProps {
10
11
  minDate?: Date
11
12
  maxDate?: Date
12
13
  locale: Locale
14
+ timezone: string
13
15
  }
14
16
 
15
17
  const props = defineProps<CalendarMonthViewProps>()
@@ -19,7 +21,7 @@ const emit = defineEmits<{
19
21
 
20
22
  const monthItems = computed(() =>
21
23
  Array.from({ length: 12 }, (_, i) => {
22
- const d = new Date(props.viewYear, i, 1)
24
+ const d = toZonedTime(new Date(Date.UTC(props.viewYear, i, 1)), props.timezone)
23
25
  const raw = format(d, 'LLL', { locale: props.locale }).replace('.', '').trim()
24
26
  return { index: i, label: raw.charAt(0).toUpperCase() + raw.slice(1) }
25
27
  }),
@@ -27,17 +29,24 @@ const monthItems = computed(() =>
27
29
 
28
30
  const isMonthDisabled = (i: number): boolean => {
29
31
  const y = props.viewYear
30
- if (props.minDate && endOfMonth(new Date(y, i)) < startOfDay(props.minDate)) return true
31
- if (props.maxDate && startOfMonth(new Date(y, i)) > endOfDay(props.maxDate)) return true
32
+ const monthDate = toZonedTime(new Date(Date.UTC(y, i, 1)), props.timezone)
33
+ if (
34
+ props.minDate &&
35
+ endOfMonth(monthDate) < startOfDay(toZonedTime(props.minDate, props.timezone))
36
+ )
37
+ return true
38
+ if (
39
+ props.maxDate &&
40
+ startOfMonth(monthDate) > endOfDay(toZonedTime(props.maxDate, props.timezone))
41
+ )
42
+ return true
32
43
  return false
33
44
  }
34
45
 
35
46
  const isMonthSelected = (i: number): boolean => {
36
- return (
37
- !!props.modelValue &&
38
- props.modelValue.getFullYear() === props.viewYear &&
39
- props.modelValue.getMonth() === i
40
- )
47
+ if (!props.modelValue) return false
48
+ const zonedModel = toZonedTime(props.modelValue, props.timezone)
49
+ return zonedModel.getFullYear() === props.viewYear && zonedModel.getMonth() === i
41
50
  }
42
51
 
43
52
  const isMonthCurrent = (i: number): boolean => {
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import CalendarYearView from './_CalendarYearView.vue'
4
+
5
+ describe('CalendarYearView', () => {
6
+ it('renders a range of years', () => {
7
+ const wrapper = mount(CalendarYearView, {
8
+ props: {
9
+ viewYear: 2024,
10
+ timezone: 'UTC',
11
+ },
12
+ })
13
+ const items = wrapper.findAll('button')
14
+ expect(items.length).toBeGreaterThan(100) // Default range is viewYear - 100 to + 20
15
+ })
16
+
17
+ it('respects minDate and maxDate', () => {
18
+ const wrapper = mount(CalendarYearView, {
19
+ props: {
20
+ viewYear: 2024,
21
+ minDate: new Date(Date.UTC(2020, 0, 1)),
22
+ maxDate: new Date(Date.UTC(2025, 0, 1)),
23
+ timezone: 'UTC',
24
+ },
25
+ })
26
+ const items = wrapper.findAll('button')
27
+ // 2020 to 2025 inclusive is 6 years
28
+ expect(items.length).toBe(6)
29
+ expect(items[0]?.text()).toBe('2020')
30
+ expect(items[5]?.text()).toBe('2025')
31
+ })
32
+
33
+ it('emits select when a year is clicked', async () => {
34
+ const wrapper = mount(CalendarYearView, {
35
+ props: {
36
+ viewYear: 2024,
37
+ timezone: 'UTC',
38
+ },
39
+ })
40
+ const year2024 = wrapper.findAll('button').find((i) => i.text() === '2024')
41
+ await year2024?.trigger('click')
42
+ expect(wrapper.emitted('select')).toBeTruthy()
43
+ expect(wrapper.emitted('select')?.[0]?.[0]).toBe(2024)
44
+ })
45
+
46
+ it('highlights the current year', async () => {
47
+ const wrapper = mount(CalendarYearView, {
48
+ props: {
49
+ viewYear: 2024,
50
+ timezone: 'UTC',
51
+ },
52
+ })
53
+ const activeYear = wrapper.find('button.text-cornflower-blue-500')
54
+ expect(activeYear.text()).toBe('2024')
55
+ })
56
+ })
@@ -1,10 +1,12 @@
1
1
  <script lang="ts" setup>
2
2
  import { ref, computed, nextTick, onMounted } from 'vue'
3
+ import { toZonedTime } from 'date-fns-tz'
3
4
 
4
5
  interface CalendarYearViewProps {
5
6
  viewYear: number
6
7
  minDate?: Date
7
8
  maxDate?: Date
9
+ timezone: string
8
10
  }
9
11
 
10
12
  const props = defineProps<CalendarYearViewProps>()
@@ -16,8 +18,12 @@ const YEAR_ITEM_H = 36 // px — matches h-9
16
18
  const yearScrollerRef = ref<HTMLElement | null>(null)
17
19
 
18
20
  const yearRange = computed(() => {
19
- const min = props.minDate ? props.minDate.getFullYear() : props.viewYear - 100
20
- const max = props.maxDate ? props.maxDate.getFullYear() : props.viewYear + 20
21
+ const min = props.minDate
22
+ ? toZonedTime(props.minDate, props.timezone).getFullYear()
23
+ : props.viewYear - 100
24
+ const max = props.maxDate
25
+ ? toZonedTime(props.maxDate, props.timezone).getFullYear()
26
+ : props.viewYear + 20
21
27
  return Array.from({ length: max - min + 1 }, (_, i) => min + i)
22
28
  })
23
29
 
@@ -7,4 +7,5 @@ export interface NexxtCalendarProps {
7
7
  minDate?: Date
8
8
  maxDate?: Date
9
9
  monthYearPicker?: boolean
10
+ timezone?: string
10
11
  }
@@ -24,12 +24,15 @@ const props = withDefaults(defineProps<NexxtDatePickerProps>(), {
24
24
 
25
25
  const PICKER_ONLY_KEYS = new Set(['placeholder', 'anchor', 'autoClose'])
26
26
 
27
- const calendarProps = computed(
28
- (): NexxtCalendarProps =>
29
- Object.fromEntries(
30
- Object.entries(props).filter(([key]) => !PICKER_ONLY_KEYS.has(key)),
31
- ) as NexxtCalendarProps,
32
- )
27
+ const calendarProps = computed((): NexxtCalendarProps => {
28
+ const commonProps = Object.fromEntries(
29
+ Object.entries(props).filter(([key]) => !PICKER_ONLY_KEYS.has(key)),
30
+ )
31
+ return {
32
+ timezone: 'UTC',
33
+ ...commonProps,
34
+ } as NexxtCalendarProps
35
+ })
33
36
 
34
37
  // ── State ─────────────────────────────────────────────────────────────────────
35
38
 
@@ -0,0 +1,291 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import TimelineEvent from './TimelineEvent.vue'
3
+
4
+ export default {
5
+ title: 'Components/Atoms/Timeline Event',
6
+ component: TimelineEvent,
7
+ } satisfies Meta<typeof TimelineEvent>
8
+
9
+ type Story = StoryObj<typeof TimelineEvent>
10
+
11
+ const baseEvent = {
12
+ id: 1,
13
+ happened_at: '2024-06-15T10:30:00.000Z',
14
+ medium_type: null,
15
+ url: null,
16
+ page_title: null,
17
+ request_reason: null,
18
+ agenda_item_type: null,
19
+ source: 'system',
20
+ direction: 'outbound',
21
+ description: null,
22
+ }
23
+
24
+ export const ContactCreated: Story = {
25
+ args: {
26
+ event: {
27
+ ...baseEvent,
28
+ type: 'contact_created',
29
+ },
30
+ splitView: true,
31
+ },
32
+ }
33
+
34
+ export const InboundEvent: Story = {
35
+ args: {
36
+ event: {
37
+ ...baseEvent,
38
+ type: 'contact_requested',
39
+ direction: 'inbound',
40
+ },
41
+ splitView: true,
42
+ },
43
+ }
44
+
45
+ export const PageViewed: Story = {
46
+ args: {
47
+ event: {
48
+ ...baseEvent,
49
+ type: 'page_viewed',
50
+ url: 'https://example.com/page',
51
+ page_title: 'Example Page',
52
+ description: 'Example Page',
53
+ },
54
+ splitView: true,
55
+ },
56
+ }
57
+
58
+ export const WhatsAppMessageReceived: Story = {
59
+ args: {
60
+ event: {
61
+ ...baseEvent,
62
+ type: 'whatsapp_message_received',
63
+ },
64
+ splitView: true,
65
+ },
66
+ }
67
+
68
+ export const WhatsAppMessageSent: Story = {
69
+ args: {
70
+ event: {
71
+ ...baseEvent,
72
+ type: 'whatsapp_message_sent',
73
+ direction: 'outbound',
74
+ },
75
+ splitView: true,
76
+ },
77
+ }
78
+
79
+ export const WithUrl: Story = {
80
+ args: {
81
+ event: {
82
+ ...baseEvent,
83
+ type: 'proposal_sent',
84
+ url: 'https://example.com/proposal/123',
85
+ description: 'Voorstel Q2 2024',
86
+ },
87
+ splitView: true,
88
+ },
89
+ }
90
+
91
+ export const WithRequestReason: Story = {
92
+ args: {
93
+ event: {
94
+ ...baseEvent,
95
+ type: 'house_viewing_requested',
96
+ request_reason: 'Interesse in de woning na open dag',
97
+ },
98
+ splitView: true,
99
+ },
100
+ }
101
+
102
+ export const SplitViewDisabled: Story = {
103
+ args: {
104
+ event: {
105
+ ...baseEvent,
106
+ type: 'contact_created',
107
+ direction: 'inbound',
108
+ },
109
+ splitView: false,
110
+ },
111
+ }
112
+
113
+ export const ViewingAgendaItemScheduled: Story = {
114
+ args: {
115
+ event: {
116
+ ...baseEvent,
117
+ type: 'viewing_agenda_item_scheduled',
118
+ },
119
+ splitView: true,
120
+ },
121
+ }
122
+
123
+ export const ProposalAccepted: Story = {
124
+ args: {
125
+ event: {
126
+ ...baseEvent,
127
+ type: 'proposal_accepted',
128
+ },
129
+ splitView: true,
130
+ },
131
+ }
132
+
133
+ export const KeyHandoverScheduled: Story = {
134
+ args: {
135
+ event: {
136
+ ...baseEvent,
137
+ type: 'key_handover_agenda_item_scheduled',
138
+ },
139
+ splitView: true,
140
+ },
141
+ }
142
+
143
+ export const AutomationPhaseOpened: Story = {
144
+ args: {
145
+ event: { ...baseEvent, type: 'automation_phase_opened' },
146
+ splitView: true,
147
+ },
148
+ }
149
+
150
+ export const AutomationWhatsAppButtonClicked: Story = {
151
+ args: {
152
+ event: { ...baseEvent, type: 'automation_whatsapp_button_clicked' },
153
+ splitView: true,
154
+ },
155
+ }
156
+
157
+ export const AutomationButtonMailAnswered: Story = {
158
+ args: {
159
+ event: { ...baseEvent, type: 'automation_button_mail_answered' },
160
+ splitView: true,
161
+ },
162
+ }
163
+
164
+ export const WaitingListSignup: Story = {
165
+ args: {
166
+ event: { ...baseEvent, type: 'waiting_list_signup' },
167
+ splitView: true,
168
+ },
169
+ }
170
+
171
+ export const ViewingCreated: Story = {
172
+ args: {
173
+ event: { ...baseEvent, type: 'viewing_created' },
174
+ splitView: true,
175
+ },
176
+ }
177
+
178
+ export const GeneralAgendaItemScheduled: Story = {
179
+ args: {
180
+ event: { ...baseEvent, type: 'general_agenda_item_scheduled' },
181
+ splitView: true,
182
+ },
183
+ }
184
+
185
+ export const SalesAgendaItemScheduled: Story = {
186
+ args: {
187
+ event: { ...baseEvent, type: 'sales_agenda_item_scheduled' },
188
+ splitView: true,
189
+ },
190
+ }
191
+
192
+ export const AgendaItemScheduled: Story = {
193
+ args: {
194
+ event: { ...baseEvent, type: 'agenda_item_scheduled' },
195
+ splitView: true,
196
+ },
197
+ }
198
+
199
+ export const AppraisalAgendaItemScheduled: Story = {
200
+ args: {
201
+ event: { ...baseEvent, type: 'appraisal_agenda_item_scheduled' },
202
+ splitView: true,
203
+ },
204
+ }
205
+
206
+ export const PurchaseAgendaItemScheduled: Story = {
207
+ args: {
208
+ event: { ...baseEvent, type: 'purchase_agenda_item_scheduled' },
209
+ splitView: true,
210
+ },
211
+ }
212
+
213
+ export const StructuralInspectionAgendaItemScheduled: Story = {
214
+ args: {
215
+ event: { ...baseEvent, type: 'structural_inspection_agenda_item_scheduled' },
216
+ splitView: true,
217
+ },
218
+ }
219
+
220
+ export const ValuationAgendaItemScheduled: Story = {
221
+ args: {
222
+ event: { ...baseEvent, type: 'valuation_agenda_item_scheduled' },
223
+ splitView: true,
224
+ },
225
+ }
226
+
227
+ export const MortgageAgendaItemScheduled: Story = {
228
+ args: {
229
+ event: { ...baseEvent, type: 'mortgage_agenda_item_scheduled' },
230
+ splitView: true,
231
+ },
232
+ }
233
+
234
+ export const PurchaseDeedSigningAgendaItemScheduled: Story = {
235
+ args: {
236
+ event: { ...baseEvent, type: 'purchase_deed_signing_agenda_item_scheduled' },
237
+ splitView: true,
238
+ },
239
+ }
240
+
241
+ export const ProposalOpened: Story = {
242
+ args: {
243
+ event: { ...baseEvent, type: 'proposal_opened' },
244
+ splitView: true,
245
+ },
246
+ }
247
+
248
+ export const FinalInspectionAgendaItemScheduled: Story = {
249
+ args: {
250
+ event: { ...baseEvent, type: 'final_inspection_agenda_item_scheduled' },
251
+ splitView: true,
252
+ },
253
+ }
254
+
255
+ export const TransportAgendaItemScheduled: Story = {
256
+ args: {
257
+ event: { ...baseEvent, type: 'transport_agenda_item_scheduled' },
258
+ splitView: true,
259
+ },
260
+ }
261
+
262
+ export const NewsletterOpened: Story = {
263
+ args: {
264
+ event: { ...baseEvent, type: 'newsletter_opened' },
265
+ splitView: true,
266
+ },
267
+ }
268
+
269
+ export const HouseValuationRequested: Story = {
270
+ args: {
271
+ event: { ...baseEvent, type: 'house_valuation_requested' },
272
+ splitView: true,
273
+ },
274
+ }
275
+
276
+ export const Unsubscribed: Story = {
277
+ args: {
278
+ event: { ...baseEvent, type: 'unsubscribed' },
279
+ splitView: true,
280
+ },
281
+ }
282
+
283
+ export const UnknownEventType: Story = {
284
+ args: {
285
+ event: {
286
+ ...baseEvent,
287
+ type: 'custom_event_type',
288
+ },
289
+ splitView: true,
290
+ },
291
+ }