@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.
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import TimelineEvent from './TimelineEvent.vue'
4
+
5
+ const baseEvent = {
6
+ id: 1,
7
+ happened_at: '2024-06-15T10:30:00.000Z',
8
+ medium_type: null,
9
+ url: null,
10
+ page_title: null,
11
+ request_reason: null,
12
+ agenda_item_type: null,
13
+ source: 'system',
14
+ direction: 'outbound',
15
+ description: null,
16
+ }
17
+
18
+ const mountEvent = (eventOverrides = {}, props = {}) =>
19
+ mount(TimelineEvent, {
20
+ props: {
21
+ event: { ...baseEvent, type: 'contact_created', ...eventOverrides },
22
+ ...props,
23
+ },
24
+ global: {
25
+ stubs: {
26
+ NexxtIcon: true,
27
+ },
28
+ },
29
+ })
30
+
31
+ describe('TimelineEvent', () => {
32
+ it('renders the formatted date', () => {
33
+ const wrapper = mountEvent()
34
+ const dateText = wrapper.find('.extra-small-normal').text()
35
+ expect(dateText).toContain('om')
36
+ })
37
+
38
+ it('sets data-event-source attribute from direction', () => {
39
+ const wrapper = mountEvent({ direction: 'inbound' })
40
+ expect(wrapper.attributes('data-event-source')).toBe('inbound')
41
+ })
42
+
43
+ it('sets data-event-date attribute from happened_at', () => {
44
+ const wrapper = mountEvent({ happened_at: '2024-06-15T10:30:00.000Z' })
45
+ expect(wrapper.attributes('data-event-date')).toBe('2024-06-15T10:30:00.000Z')
46
+ })
47
+
48
+ it('sets date-event-type attribute from type', () => {
49
+ const wrapper = mountEvent({ type: 'page_viewed' })
50
+ expect(wrapper.attributes('date-event-type')).toBe('page_viewed')
51
+ })
52
+
53
+ it('renders the event label for a known event type', () => {
54
+ const wrapper = mountEvent({ type: 'contact_created' })
55
+ expect(wrapper.text()).toContain('Contact aangemaakt')
56
+ })
57
+
58
+ it('renders the event type as label for an unknown event type', () => {
59
+ const wrapper = mountEvent({ type: 'custom_unknown_type' })
60
+ expect(wrapper.text()).toContain('custom_unknown_type')
61
+ })
62
+
63
+ it('does not render a link when event.url is null', () => {
64
+ const wrapper = mountEvent({ url: null })
65
+ expect(wrapper.find('a').exists()).toBe(false)
66
+ })
67
+
68
+ it('renders a link when event.url is set', () => {
69
+ const wrapper = mountEvent({ url: 'https://example.com' })
70
+ const link = wrapper.find('a')
71
+ expect(link.exists()).toBe(true)
72
+ expect(link.attributes('href')).toBe('https://example.com')
73
+ expect(link.attributes('target')).toBe('_blank')
74
+ })
75
+
76
+ it('renders event.description as link text when set', () => {
77
+ const wrapper = mountEvent({ url: 'https://example.com', description: 'Mijn voorstel' })
78
+ expect(wrapper.find('a').text()).toContain('Mijn voorstel')
79
+ })
80
+
81
+ it('renders event.url as link text when description is null', () => {
82
+ const wrapper = mountEvent({ url: 'https://example.com', description: null })
83
+ expect(wrapper.find('a').text()).toContain('https://example.com')
84
+ })
85
+
86
+ it('does not render request_reason when not set', () => {
87
+ const wrapper = mountEvent({ request_reason: null })
88
+ expect(wrapper.text()).not.toContain('Reden:')
89
+ })
90
+
91
+ it('renders request_reason when set', () => {
92
+ const wrapper = mountEvent({ request_reason: 'Interesse in de woning' })
93
+ expect(wrapper.text()).toContain('Reden: Interesse in de woning')
94
+ })
95
+
96
+ it('aligns outbound events to the left by default (no justify-end)', () => {
97
+ const wrapper = mountEvent({ direction: 'outbound' }, { splitView: true })
98
+ expect(wrapper.classes()).not.toContain('justify-end')
99
+ })
100
+
101
+ it('aligns inbound events to the right when splitView is true', () => {
102
+ const wrapper = mountEvent({ direction: 'inbound' }, { splitView: true })
103
+ expect(wrapper.classes()).toContain('justify-end')
104
+ })
105
+
106
+ it('does not align inbound events to the right when splitView is false', () => {
107
+ const wrapper = mountEvent({ direction: 'inbound' }, { splitView: false })
108
+ expect(wrapper.classes()).not.toContain('justify-end')
109
+ })
110
+
111
+ it('reverses icon and content order for inbound events in splitView', () => {
112
+ const wrapper = mountEvent({ direction: 'inbound' }, { splitView: true })
113
+ const innerRow = wrapper.find('.flex.w-full.shrink-0')
114
+ expect(innerRow.classes()).toContain('flex-row-reverse')
115
+ })
116
+
117
+ it('does not reverse order for outbound events', () => {
118
+ const wrapper = mountEvent({ direction: 'outbound' }, { splitView: true })
119
+ const innerRow = wrapper.find('.flex.w-full.shrink-0')
120
+ expect(innerRow.classes()).not.toContain('flex-row-reverse')
121
+ })
122
+
123
+ it('applies max-w-lg to content when splitView is true', () => {
124
+ const wrapper = mountEvent({}, { splitView: true })
125
+ const content = wrapper.find('.rounded-xl')
126
+ expect(content.classes()).toContain('max-w-lg')
127
+ })
128
+
129
+ it('does not apply max-w-lg to content when splitView is false', () => {
130
+ const wrapper = mountEvent({}, { splitView: false })
131
+ const content = wrapper.find('.rounded-xl')
132
+ expect(content.classes()).not.toContain('max-w-lg')
133
+ })
134
+
135
+ it('defaults splitView to true', () => {
136
+ const wrapper = mount(TimelineEvent, {
137
+ props: {
138
+ event: { ...baseEvent, type: 'contact_created', direction: 'inbound' },
139
+ },
140
+ global: { stubs: { NexxtIcon: true } },
141
+ })
142
+ expect(wrapper.classes()).toContain('justify-end')
143
+ })
144
+
145
+ it('applies correct background color class for known event type', () => {
146
+ const wrapper = mountEvent({ type: 'contact_created' })
147
+ const iconWrapper = wrapper.find('.rounded-full')
148
+ expect(iconWrapper.classes()).toContain('bg-pink-500')
149
+ })
150
+
151
+ it('applies default background color for unknown event type', () => {
152
+ const wrapper = mountEvent({ type: 'unknown_type' })
153
+ const iconWrapper = wrapper.find('.rounded-full')
154
+ expect(iconWrapper.classes()).toContain('bg-gray-500')
155
+ })
156
+
157
+ it('aligns date text to the right for inbound events in splitView', () => {
158
+ const wrapper = mountEvent({ direction: 'inbound' }, { splitView: true })
159
+ expect(wrapper.find('.extra-small-normal').classes()).toContain('text-right')
160
+ })
161
+
162
+ it('does not align date text to the right for outbound events', () => {
163
+ const wrapper = mountEvent({ direction: 'outbound' }, { splitView: true })
164
+ expect(wrapper.find('.extra-small-normal').classes()).not.toContain('text-right')
165
+ })
166
+ })
@@ -0,0 +1,111 @@
1
+ <script lang="ts" setup>
2
+ import NexxtIcon from '../Icon/Icon.vue'
3
+ import { computed } from 'vue'
4
+ import { EVENT_CONFIG_MAP, type TimelineEvent as Event } from './TimelineTypes'
5
+
6
+ interface EventConfig {
7
+ label: string
8
+ icon: string
9
+ color: string
10
+ }
11
+
12
+ const defaultEventConfig: EventConfig = {
13
+ label: 'Gebeurtenis',
14
+ icon: 'info',
15
+ color: 'bg-gray-500',
16
+ }
17
+
18
+ const eventConfig = computed(() => {
19
+ return EVENT_CONFIG_MAP[props.event.type] || { ...defaultEventConfig, label: props.event.type }
20
+ })
21
+
22
+ const props = withDefaults(
23
+ defineProps<{
24
+ event: Event
25
+ splitView?: boolean
26
+ }>(),
27
+ {
28
+ splitView: true,
29
+ },
30
+ )
31
+
32
+ const formattedDate = computed(() => {
33
+ const date = new Date(props.event.happened_at)
34
+ const datePart = date.toLocaleDateString('nl-NL', {
35
+ day: 'numeric',
36
+ month: 'short',
37
+ year: 'numeric',
38
+ })
39
+ const timePart = date.toLocaleTimeString('nl-NL', {
40
+ hour: '2-digit',
41
+ minute: '2-digit',
42
+ })
43
+
44
+ return `${datePart} om ${timePart}`
45
+ })
46
+
47
+ const isContactEvent = computed(() => {
48
+ return props.splitView && props.event.direction === 'inbound'
49
+ })
50
+ </script>
51
+
52
+ <template>
53
+ <div
54
+ :data-event-source="event.direction"
55
+ :data-event-date="event.happened_at"
56
+ :date-event-type="event.type"
57
+ class="relative z-20 flex flex-wrap items-center gap-x-6 gap-y-2"
58
+ :class="{
59
+ 'justify-end': isContactEvent,
60
+ }"
61
+ >
62
+ <div
63
+ class="w-full px-24 extra-small-normal text-gray-600"
64
+ :class="{
65
+ 'text-right': isContactEvent,
66
+ }"
67
+ >
68
+ {{ formattedDate }}
69
+ </div>
70
+
71
+ <div
72
+ class="flex w-full shrink-0 items-center gap-6"
73
+ :class="{
74
+ 'flex-row-reverse': isContactEvent,
75
+ }"
76
+ >
77
+ <div
78
+ class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full"
79
+ :class="[eventConfig.color]"
80
+ >
81
+ <NexxtIcon
82
+ :name="eventConfig.icon as any"
83
+ :type="eventConfig.icon === 'whatsapp' ? 'brands' : 'light'"
84
+ class="flex h-6 w-6 text-xl text-white"
85
+ />
86
+ </div>
87
+ <div
88
+ class="flex w-full flex-col gap-2 rounded-xl border border-gray-200 p-8"
89
+ :class="{ 'max-w-lg': splitView }"
90
+ >
91
+ <span class="heading-3">
92
+ {{ eventConfig.label }}
93
+ </span>
94
+ <a
95
+ v-if="event.url"
96
+ :href="event.url"
97
+ target="_blank"
98
+ class="group mt-1 flex justify-between gap-2 text-sm text-cornflower-blue-500 hover:text-cornflower-blue-600"
99
+ >
100
+ <span class="truncate group-hover:underline">
101
+ {{ event.description || event.url }}
102
+ </span>
103
+ <NexxtIcon name="external-link" class="ml-1 h-4 w-4 text-gray-950" />
104
+ </a>
105
+ <span v-if="event.request_reason" class="mt-1 text-sm text-gray-500">
106
+ Reden: {{ event.request_reason }}
107
+ </span>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </template>
@@ -0,0 +1,236 @@
1
+ export interface TimelineEvent {
2
+ id: number
3
+ type: string
4
+ happened_at: string
5
+ medium_type: string | null
6
+ url: string | null
7
+ page_title: string | null
8
+ request_reason: string | null
9
+ agenda_item_type: string | null
10
+ source: string
11
+ direction: string
12
+ description: string | null
13
+ }
14
+
15
+ export interface TimelinePhase {
16
+ phase: {
17
+ value: number
18
+ }
19
+ entered_at: string | null
20
+ exited_at: string | null
21
+ events: TimelineEvent[]
22
+ }
23
+
24
+ export interface TimelineResponse {
25
+ data: {
26
+ current_phase: {
27
+ value: number
28
+ entered_at?: string
29
+ }
30
+ phases: TimelinePhase[]
31
+ }
32
+ }
33
+
34
+ export interface PhaseConfig {
35
+ label: string
36
+ color: string
37
+ bgColor: string
38
+ fromColor: string
39
+ toColor: string
40
+ textColor: string
41
+ }
42
+
43
+ export const EVENT_CONFIG_MAP: Record<string, { label: string; icon: string; color: string }> = {
44
+ // FieldResearch / Awareness
45
+ contact_created: { label: 'Contact aangemaakt', icon: 'user-plus', color: 'bg-pink-500' },
46
+ page_viewed: { label: 'Pagina bekeken', icon: 'eye', color: 'bg-blue-500' },
47
+ house_viewing_requested: {
48
+ label: 'Bezichtiging aangevraagd',
49
+ icon: 'house-chimney',
50
+ color: 'bg-orange-500',
51
+ },
52
+ automation_phase_opened: {
53
+ label: 'Automation e-mail geopend',
54
+ icon: 'envelope-open-text',
55
+ color: 'bg-indigo-500',
56
+ },
57
+ automation_whatsapp_button_clicked: {
58
+ label: 'WhatsApp knop geklikt',
59
+ icon: 'whatsapp',
60
+ color: 'bg-[#25D366]',
61
+ },
62
+ automation_button_mail_answered: {
63
+ label: 'Automation mail beantwoord',
64
+ icon: 'reply',
65
+ color: 'bg-indigo-600',
66
+ },
67
+ waiting_list_signup: {
68
+ label: 'Aangemeld voor wachtlijst',
69
+ icon: 'list-check',
70
+ color: 'bg-yellow-500',
71
+ },
72
+ viewing_created: {
73
+ label: 'Bezichtiging aangemaakt',
74
+ icon: 'house',
75
+ color: 'bg-orange-400',
76
+ },
77
+ viewing_agenda_item_scheduled: {
78
+ label: 'Bezichtiging gepland',
79
+ icon: 'calendar-check',
80
+ color: 'bg-orange-400',
81
+ },
82
+ general_agenda_item_scheduled: {
83
+ label: 'Afspraak gepland',
84
+ icon: 'calendar',
85
+ color: 'bg-gray-400',
86
+ },
87
+ sales_agenda_item_scheduled: {
88
+ label: 'Verkoopgesprek gepland',
89
+ icon: 'handshake',
90
+ color: 'bg-blue-500',
91
+ },
92
+ agenda_item_scheduled: {
93
+ label: 'Agenda-item gepland',
94
+ icon: 'calendar-plus',
95
+ color: 'bg-gray-500',
96
+ },
97
+ whatsapp_message_sent: {
98
+ label: 'WhatsApp-bericht verzonden',
99
+ icon: 'paper-plane',
100
+ color: 'bg-[#25D366]',
101
+ },
102
+ whatsapp_message_received: {
103
+ label: 'WhatsApp-bericht ontvangen',
104
+ icon: 'whatsapp',
105
+ color: 'bg-[#25D366]',
106
+ },
107
+
108
+ // Transaction
109
+ appraisal_agenda_item_scheduled: {
110
+ label: 'Taxatie gepland',
111
+ icon: 'file-invoice-dollar',
112
+ color: 'bg-teal-500',
113
+ },
114
+ purchase_agenda_item_scheduled: {
115
+ label: 'Aankoopgesprek gepland',
116
+ icon: 'cart-shopping',
117
+ color: 'bg-emerald-600',
118
+ },
119
+ structural_inspection_agenda_item_scheduled: {
120
+ label: 'Bouwkundige keuring gepland',
121
+ icon: 'hard-hat',
122
+ color: 'bg-amber-600',
123
+ },
124
+ valuation_agenda_item_scheduled: {
125
+ label: 'Waardebepaling gepland',
126
+ icon: 'calculator',
127
+ color: 'bg-teal-600',
128
+ },
129
+ mortgage_agenda_item_scheduled: {
130
+ label: 'Hypotheekgesprek gepland',
131
+ icon: 'money-check-dollar-pen',
132
+ color: 'bg-blue-600',
133
+ },
134
+ purchase_deed_signing_agenda_item_scheduled: {
135
+ label: 'Koopovereenkomst tekenen',
136
+ icon: 'pen-fancy',
137
+ color: 'bg-emerald-500',
138
+ },
139
+ proposal_sent: { label: 'Voorstel verzonden', icon: 'paper-plane-top', color: 'bg-blue-400' },
140
+ proposal_opened: { label: 'Voorstel geopend', icon: 'envelope-open', color: 'bg-blue-300' },
141
+ proposal_accepted: { label: 'Voorstel geaccepteerd', icon: 'check', color: 'bg-emerald-500' },
142
+
143
+ // Aftercare
144
+ final_inspection_agenda_item_scheduled: {
145
+ label: 'Eindinspectie gepland',
146
+ icon: 'clipboard-check',
147
+ color: 'bg-green-400',
148
+ },
149
+ key_handover_agenda_item_scheduled: {
150
+ label: 'Sleuteloverdracht gepland',
151
+ icon: 'key',
152
+ color: 'bg-green-500',
153
+ },
154
+ transport_agenda_item_scheduled: {
155
+ label: 'Transport bij notaris',
156
+ icon: 'file-signature',
157
+ color: 'bg-green-600',
158
+ },
159
+
160
+ // Other / Awareness
161
+ newsletter_opened: {
162
+ label: 'Nieuwsbrief geopend',
163
+ icon: 'envelope-open',
164
+ color: 'bg-indigo-400',
165
+ },
166
+ contact_requested: { label: 'Contactaanvraag', icon: 'user', color: 'bg-indigo-500' },
167
+ house_valuation_requested: {
168
+ label: 'Waardebepaling aangevraagd',
169
+ icon: 'search',
170
+ color: 'bg-teal-500',
171
+ },
172
+ unsubscribed: {
173
+ label: 'Afgemeld',
174
+ icon: 'user-slash',
175
+ color: 'bg-red-500',
176
+ },
177
+ }
178
+
179
+ export const PHASE_MAP: Record<number, PhaseConfig> = {
180
+ 0: {
181
+ label: 'Inactief',
182
+ color: 'border-slate-300',
183
+ bgColor: 'bg-slate-300',
184
+ fromColor: 'from-slate-300',
185
+ toColor: 'to-slate-300',
186
+ textColor: 'text-gray-700',
187
+ },
188
+ 1: {
189
+ label: 'Latente woonfase',
190
+ color: 'border-amber-500',
191
+ bgColor: 'bg-amber-500',
192
+ fromColor: 'from-amber-500',
193
+ toColor: 'to-amber-500',
194
+ textColor: 'text-white',
195
+ },
196
+ 2: {
197
+ label: 'Oriëntatie',
198
+ color: 'border-purple-500',
199
+ bgColor: 'bg-purple-500',
200
+ fromColor: 'from-purple-500',
201
+ toColor: 'to-purple-500',
202
+ textColor: 'text-white',
203
+ },
204
+ 3: {
205
+ label: 'Actieve zoektocht',
206
+ color: 'border-yellow-500',
207
+ bgColor: 'bg-yellow-500',
208
+ fromColor: 'from-yellow-500',
209
+ toColor: 'to-yellow-500',
210
+ textColor: 'text-white',
211
+ },
212
+ 4: {
213
+ label: 'Selectie',
214
+ color: 'border-blue-500',
215
+ bgColor: 'bg-blue-500',
216
+ fromColor: 'from-blue-500',
217
+ toColor: 'to-blue-500',
218
+ textColor: 'text-white',
219
+ },
220
+ 5: {
221
+ label: 'Transactie',
222
+ color: 'border-emerald-500',
223
+ bgColor: 'bg-emerald-500',
224
+ fromColor: 'from-emerald-500',
225
+ toColor: 'to-emerald-500',
226
+ textColor: 'text-white',
227
+ },
228
+ 6: {
229
+ label: 'Wonen',
230
+ color: 'border-green-500',
231
+ bgColor: 'bg-green-500',
232
+ fromColor: 'from-green-500',
233
+ toColor: 'to-green-500',
234
+ textColor: 'text-white',
235
+ },
236
+ }
@@ -0,0 +1,198 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite'
2
+ import TimelinePhaseblock from './TimelinePhaseblock.vue'
3
+ import type { TimelinePhase } from '../TimelineEvent/TimelineTypes'
4
+
5
+ export default {
6
+ title: 'Components/Molecules/Timeline Phaseblock',
7
+ component: TimelinePhaseblock,
8
+ } satisfies Meta<typeof TimelinePhaseblock>
9
+
10
+ type Story = StoryObj<typeof TimelinePhaseblock>
11
+
12
+ const makePhase = (
13
+ value: number,
14
+ entered_at: string | null = '2024-06-01T09:00:00.000Z',
15
+ exited_at: string | null = '2024-07-01T09:00:00.000Z',
16
+ events: TimelinePhase['events'] = [],
17
+ ): TimelinePhase => ({
18
+ phase: { value },
19
+ entered_at,
20
+ exited_at,
21
+ events,
22
+ })
23
+
24
+ const sampleEvent = {
25
+ id: 1,
26
+ type: 'contact_created',
27
+ happened_at: '2024-06-10T10:30:00.000Z',
28
+ medium_type: null,
29
+ url: null,
30
+ page_title: null,
31
+ request_reason: null,
32
+ agenda_item_type: null,
33
+ source: 'system',
34
+ direction: 'outbound',
35
+ description: null,
36
+ }
37
+
38
+ // Single phase at the end of the timeline (no separator shown)
39
+ export const SinglePhase: Story = {
40
+ args: {
41
+ phase: makePhase(1),
42
+ index: 0,
43
+ phases: [makePhase(1)],
44
+ isSplitView: true,
45
+ isLast: true,
46
+ },
47
+ }
48
+
49
+ // First phase (index 0) in split view — shows the Outbound/Inbound header
50
+ export const FirstPhaseWithHeader: Story = {
51
+ args: {
52
+ phase: makePhase(4),
53
+ index: 0,
54
+ phases: [makePhase(4), makePhase(2)],
55
+ isSplitView: true,
56
+ isLast: false,
57
+ },
58
+ }
59
+
60
+ // Phase separator visible — phase progressed upward (arrow up)
61
+ export const PhaseProgressedUp: Story = {
62
+ args: {
63
+ phase: makePhase(4),
64
+ index: 0,
65
+ phases: [makePhase(4), makePhase(2)],
66
+ isSplitView: true,
67
+ isLast: false,
68
+ },
69
+ }
70
+
71
+ // Phase separator visible — phase regressed downward (arrow down)
72
+ export const PhaseRegressedDown: Story = {
73
+ args: {
74
+ phase: makePhase(2),
75
+ index: 0,
76
+ phases: [makePhase(2), makePhase(4)],
77
+ isSplitView: true,
78
+ isLast: false,
79
+ },
80
+ }
81
+
82
+ // Last item in a multi-phase list — no separator shown
83
+ export const LastInList: Story = {
84
+ args: {
85
+ phase: makePhase(1),
86
+ index: 1,
87
+ phases: [makePhase(4), makePhase(1)],
88
+ isSplitView: true,
89
+ isLast: true,
90
+ },
91
+ }
92
+
93
+ // Non-split view — with phase separator
94
+ export const NonSplitView: Story = {
95
+ args: {
96
+ phase: makePhase(3),
97
+ index: 0,
98
+ phases: [makePhase(3), makePhase(1)],
99
+ isSplitView: false,
100
+ isLast: false,
101
+ },
102
+ }
103
+
104
+ // Non-split view — single/last phase (no separator)
105
+ export const NonSplitViewLast: Story = {
106
+ args: {
107
+ phase: makePhase(3),
108
+ index: 0,
109
+ phases: [makePhase(3)],
110
+ isSplitView: false,
111
+ isLast: true,
112
+ },
113
+ }
114
+
115
+ // Non-split view — with events
116
+ export const NonSplitViewWithEvents: Story = {
117
+ args: {
118
+ phase: makePhase(4, '2024-06-01T09:00:00.000Z', '2024-07-01T09:00:00.000Z', [
119
+ sampleEvent,
120
+ { ...sampleEvent, id: 2, type: 'page_viewed', happened_at: '2024-06-12T14:00:00.000Z' },
121
+ ]),
122
+ index: 0,
123
+ phases: [makePhase(4), makePhase(2)],
124
+ isSplitView: false,
125
+ isLast: false,
126
+ },
127
+ }
128
+
129
+ // Phase with events
130
+ export const WithEvents: Story = {
131
+ args: {
132
+ phase: makePhase(4, '2024-06-01T09:00:00.000Z', '2024-07-01T09:00:00.000Z', [
133
+ sampleEvent,
134
+ { ...sampleEvent, id: 2, type: 'page_viewed', happened_at: '2024-06-12T14:00:00.000Z' },
135
+ {
136
+ ...sampleEvent,
137
+ id: 3,
138
+ type: 'whatsapp_message_received',
139
+ direction: 'inbound',
140
+ happened_at: '2024-06-15T11:00:00.000Z',
141
+ },
142
+ ]),
143
+ index: 0,
144
+ phases: [makePhase(4), makePhase(2)],
145
+ isSplitView: true,
146
+ isLast: false,
147
+ },
148
+ }
149
+
150
+ // All phases shown together (newest first)
151
+ export const FullTimeline: Story = {
152
+ args: {
153
+ phase: makePhase(5, '2024-08-01T09:00:00.000Z', null, [
154
+ { ...sampleEvent, id: 4, type: 'proposal_accepted', happened_at: '2024-08-05T10:00:00.000Z' },
155
+ ]),
156
+ index: 0,
157
+ phases: [
158
+ makePhase(5, '2024-08-01T09:00:00.000Z', null),
159
+ makePhase(4, '2024-06-01T09:00:00.000Z', '2024-08-01T09:00:00.000Z'),
160
+ makePhase(1, '2024-01-01T09:00:00.000Z', '2024-06-01T09:00:00.000Z'),
161
+ ],
162
+ isSplitView: true,
163
+ isLast: false,
164
+ },
165
+ }
166
+
167
+ // Phase with no entered_at or exited_at dates
168
+ export const NoDates: Story = {
169
+ args: {
170
+ phase: makePhase(2, null, null),
171
+ index: 0,
172
+ phases: [makePhase(2, null, null), makePhase(1, null, null)],
173
+ isSplitView: true,
174
+ isLast: false,
175
+ },
176
+ }
177
+
178
+ // Inactive phase (value 0)
179
+ export const InactivePhase: Story = {
180
+ args: {
181
+ phase: makePhase(0),
182
+ index: 0,
183
+ phases: [makePhase(0)],
184
+ isSplitView: true,
185
+ isLast: true,
186
+ },
187
+ }
188
+
189
+ // Wonen phase (value 6 — highest)
190
+ export const WonenPhase: Story = {
191
+ args: {
192
+ phase: makePhase(6),
193
+ index: 0,
194
+ phases: [makePhase(6), makePhase(5)],
195
+ isSplitView: true,
196
+ isLast: false,
197
+ },
198
+ }