@nyaruka/temba-components 0.163.0 → 0.165.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/DEV_DATA.md +11 -11
  3. package/README.md +4 -4
  4. package/TEST_OPTIMIZATION.md +8 -11
  5. package/dist/locales/es.js +4 -0
  6. package/dist/locales/es.js.map +1 -1
  7. package/dist/locales/fr.js +4 -0
  8. package/dist/locales/fr.js.map +1 -1
  9. package/dist/locales/pt.js +4 -0
  10. package/dist/locales/pt.js.map +1 -1
  11. package/dist/static/svg/index.svg +1 -1
  12. package/dist/temba-components.js +5266 -3031
  13. package/dist/temba-components.js.map +1 -1
  14. package/orca/setup.sh +2 -2
  15. package/package.json +13 -18
  16. package/scripts/dev-data-sync.mjs +4 -4
  17. package/src/Icons.ts +3 -2
  18. package/src/display/Chat.ts +3 -2
  19. package/src/display/Lightbox.ts +57 -109
  20. package/src/display/ProgressBar.ts +15 -2
  21. package/src/display/Thumbnail.ts +34 -7
  22. package/src/form/Compose.ts +31 -1
  23. package/src/form/ContactSearch.ts +229 -126
  24. package/src/interfaces.ts +131 -2
  25. package/src/layout/Card.ts +336 -0
  26. package/src/layout/CardLayout.ts +425 -0
  27. package/src/layout/CardStack.ts +131 -0
  28. package/src/layout/Dialog.ts +33 -24
  29. package/src/layout/HeaderBar.ts +41 -0
  30. package/src/layout/PageHeader.ts +5 -6
  31. package/src/layout/Resizer.ts +20 -0
  32. package/src/list/BroadcastList.ts +912 -0
  33. package/src/list/CampaignList.ts +144 -0
  34. package/src/list/ContactList.ts +3 -1
  35. package/src/list/ContentList.ts +46 -10
  36. package/src/list/FieldList.ts +1057 -0
  37. package/src/list/FlowList.ts +31 -7
  38. package/src/list/MsgList.ts +18 -12
  39. package/src/list/SortableList.ts +52 -8
  40. package/src/list/TembaList.ts +8 -0
  41. package/src/list/TembaMenu.ts +5 -38
  42. package/src/list/TriggerList.ts +538 -0
  43. package/src/live/CampaignEvents.ts +1108 -0
  44. package/src/live/ContactChat.ts +7 -1
  45. package/src/live/ContactDetails.ts +20 -14
  46. package/src/live/ContactFieldEditor.ts +7 -3
  47. package/src/live/ContactFields.ts +1 -1
  48. package/src/live/ContactNameFetch.ts +5 -2
  49. package/src/live/ContactNotepad.ts +88 -2
  50. package/src/live/ContactStoreElement.ts +15 -3
  51. package/src/live/ContactTimeline.ts +28 -7
  52. package/src/locales/es.ts +4 -0
  53. package/src/locales/fr.ts +4 -0
  54. package/src/locales/pt.ts +4 -0
  55. package/src/utils.ts +19 -0
  56. package/static/svg/index.svg +1 -1
  57. package/static/svg/packs/2-temba/star-filled.svg +3 -0
  58. package/static/svg/work/traced/star-filled.svg +1 -0
  59. package/static/svg/work/used/star-filled.svg +3 -0
  60. package/stress-test.js +3 -3
  61. package/temba-modules.ts +18 -0
  62. package/web-dev-server.config.mjs +54 -0
  63. package/web-test-runner.config.mjs +1 -1
  64. package/xliff/es.xlf +12 -0
  65. package/xliff/fr.xlf +12 -0
  66. package/xliff/pt.xlf +12 -0
@@ -0,0 +1,1108 @@
1
+ import { css, html, PropertyValueMap, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import {
4
+ CampaignScheduleEvent,
5
+ CustomEventType,
6
+ ObjectReference
7
+ } from '../interfaces';
8
+ import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
9
+ import { Icon } from '../Icons';
10
+ import { designTokens } from '../styles/designTokens';
11
+
12
+ interface CampaignEventsResponse {
13
+ events: CampaignScheduleEvent[];
14
+ campaign?: ObjectReference;
15
+ can_edit?: boolean;
16
+ can_delete?: boolean;
17
+ }
18
+
19
+ // one row in the recent-contacts popup
20
+ interface RecentFire {
21
+ contact: ObjectReference;
22
+ time: string;
23
+ }
24
+
25
+ // distinct hues assigned per relative-to field so each field's "line" reads
26
+ // clearly apart - the same palette (and assignment scheme) the contact
27
+ // timeline uses for campaigns, so colors mean "grouping" in both places
28
+ const FIELD_COLORS = [
29
+ '#1a86d0', // blue
30
+ '#e8843f', // orange
31
+ '#ec407a', // pink
32
+ '#2bb2a6', // teal
33
+ '#0a5290', // deep blue
34
+ '#a25320', // deep orange
35
+ '#a02153', // deep pink
36
+ '#176a61' // deep teal
37
+ ];
38
+
39
+ // uppercase the first letter of a localized display string - offset-0
40
+ // events lead the detail schedule with "On <field>"
41
+ const capitalize = (text: string): string =>
42
+ text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
43
+
44
+ // a section is one subway line: all the events anchored to the same date
45
+ // field, split into those firing before the field's date and those firing
46
+ // on or after it
47
+ interface Section {
48
+ key: string;
49
+ name: string;
50
+ system: boolean;
51
+ before: CampaignScheduleEvent[];
52
+ onAfter: CampaignScheduleEvent[];
53
+ }
54
+
55
+ export class CampaignEvents extends EndpointMonitorElement {
56
+ @property({ type: String })
57
+ campaign: string;
58
+
59
+ // optional endpoint override, defaults to the campaign's events endpoint
60
+ @property({ type: String })
61
+ endpoint: string;
62
+
63
+ // title shown in the page header; a `title` slot overrides it
64
+ @property({ type: String, attribute: 'header-title' })
65
+ headerTitle = '';
66
+
67
+ // subtitle under the title; a `subtitle` slot overrides it
68
+ @property({ type: String })
69
+ subtitle = '';
70
+
71
+ // GET endpoint for the page's content menu (rapidpro's content-menu view);
72
+ // menu clicks fire temba-selection with the item for the host to dispatch
73
+ @property({ type: String, attribute: 'content-menu-endpoint' })
74
+ contentMenuEndpoint = '';
75
+
76
+ @property({ type: Object, attribute: false })
77
+ data: CampaignEventsResponse;
78
+
79
+ @property({ type: String })
80
+ lang_scheduling = 'Scheduling';
81
+
82
+ @property({ type: String })
83
+ lang_edit = 'Edit';
84
+
85
+ @property({ type: String })
86
+ lang_delete = 'Delete';
87
+
88
+ @property({ type: String })
89
+ lang_recent_contacts = 'Recent Contacts';
90
+
91
+ @property({ type: String })
92
+ lang_scheduled = 'scheduled';
93
+
94
+ @property({ type: String })
95
+ lang_scheduled_contacts = 'Contacts scheduled for this event';
96
+
97
+ @property({ type: String })
98
+ lang_send_message = 'Send Message';
99
+
100
+ @property({ type: String })
101
+ lang_start_flow = 'Start Flow';
102
+
103
+ @property({ type: String })
104
+ lang_empty = 'No events';
105
+
106
+ @property({ type: String })
107
+ lang_empty_help =
108
+ 'Events send a message or start a flow for each contact in the group, offset from a date field on the contact.';
109
+
110
+ // the event whose detail modal is open
111
+ @state()
112
+ private detailEvent: CampaignScheduleEvent = null;
113
+
114
+ // the detail modal's recent contacts - null while the fetch is in flight
115
+ @state()
116
+ private fires: RecentFire[] = null;
117
+
118
+ static get styles() {
119
+ return css`
120
+ ${designTokens}
121
+
122
+ /* The page-header bar at the top, then one card per anchor field
123
+ in a padded region below. */
124
+ :host {
125
+ display: flex;
126
+ flex-direction: column;
127
+ font-family: var(--font);
128
+ color: var(--text-1);
129
+ font-size: 13.5px;
130
+ }
131
+
132
+ /* The header is the same flush surface bar the fill-window lists
133
+ render — full width, the lists' 12px inset, no card chrome —
134
+ so a list page and this read page share one header treatment. */
135
+ .header-panel {
136
+ background: var(--surface);
137
+ padding: 0 12px;
138
+ border-bottom: 1px solid var(--border);
139
+ }
140
+
141
+ /* Card panel — the same surface treatment as the content lists so
142
+ the read page reads as part of the same family. The anchor dots
143
+ and tint mixes paint with the widget background, so inside a
144
+ card that must be the card surface. */
145
+ .panel {
146
+ background: var(--surface);
147
+ border-radius: var(--r);
148
+ overflow: hidden;
149
+ box-shadow: var(--shadow-1);
150
+ --color-widget-bg: var(--surface);
151
+ }
152
+
153
+ /* badges (group, archived) get their own row between the header and
154
+ the cards. Slotted links shouldn't pick up anchor styling - the
155
+ labels inside them carry the look. */
156
+ .badges {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 8px;
160
+ padding: 12px 12px 0;
161
+ }
162
+
163
+ .badges ::slotted(*) {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 8px;
167
+ text-decoration: none;
168
+ }
169
+
170
+ /* the per-field cards stack in a padded region below the header */
171
+ .content {
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: 12px;
175
+ padding: 12px;
176
+ }
177
+
178
+ /* the section cards sit inside the events wrapper, which stays out
179
+ of layout so the cards participate in the content's column gap */
180
+ .events {
181
+ display: contents;
182
+ }
183
+
184
+ /* empty state follows the list design system: centered icon, a short
185
+ title and muted explanatory copy */
186
+ .empty {
187
+ display: flex;
188
+ flex-direction: column;
189
+ align-items: center;
190
+ text-align: center;
191
+ padding: 4em 1em;
192
+ color: var(--text-color);
193
+ }
194
+
195
+ .empty temba-icon {
196
+ margin-bottom: 0.75em;
197
+ --icon-color: var(--text-3, #7b8593);
198
+ }
199
+
200
+ .empty-title {
201
+ font-weight: 600;
202
+ margin-bottom: 0.4em;
203
+ }
204
+
205
+ .empty-help {
206
+ font-size: 0.875em;
207
+ line-height: 1.5;
208
+ max-width: 22em;
209
+ margin-bottom: 1em;
210
+ color: var(--text-3, #7b8593);
211
+ }
212
+
213
+ /* one subway line per anchor field, each in its own card */
214
+ .section {
215
+ display: flex;
216
+ flex-direction: column;
217
+ padding: 0.9em 12px 1em;
218
+ }
219
+
220
+ .row {
221
+ display: flex;
222
+ align-items: stretch;
223
+ }
224
+
225
+ /* the schedule offset ("3 days before"), vertically centered on its
226
+ dot. mute via color (not opacity) so the hovered delivery-hour
227
+ tooltip renders at full alpha */
228
+ .time {
229
+ width: 9em;
230
+ flex-shrink: 0;
231
+ display: flex;
232
+ align-items: center;
233
+ justify-content: flex-end;
234
+ padding-right: 0.65em;
235
+ font-size: 0.8em;
236
+ color: var(--text-3, #7b8593);
237
+ white-space: nowrap;
238
+ }
239
+
240
+ .time temba-tip {
241
+ cursor: default;
242
+ }
243
+
244
+ /* the subway rail */
245
+ .track {
246
+ display: flex;
247
+ flex-direction: column;
248
+ align-items: center;
249
+ width: 1.75em;
250
+ flex-shrink: 0;
251
+ }
252
+
253
+ .line {
254
+ width: 2px;
255
+ flex: 1;
256
+ background: var(--color-borders, rgba(0, 0, 0, 0.12));
257
+ }
258
+
259
+ .line.hidden {
260
+ background: transparent;
261
+ }
262
+
263
+ .dot {
264
+ width: 18px;
265
+ height: 18px;
266
+ border-radius: 50%;
267
+ box-sizing: border-box;
268
+ flex-shrink: 0;
269
+ z-index: 1;
270
+ border: 2px solid var(--dot-color);
271
+ display: flex;
272
+ align-items: center;
273
+ justify-content: center;
274
+ /* icons inside the dot take the dot's hue */
275
+ --icon-color: var(--dot-color);
276
+ background: color-mix(
277
+ in srgb,
278
+ var(--dot-color) 12%,
279
+ var(--color-widget-bg, #fff)
280
+ );
281
+ }
282
+
283
+ /* the anchor marks the field's date itself - painted with the widget
284
+ background so it masks the rail behind it and reads as the station
285
+ the line is built around */
286
+ .dot.anchor {
287
+ background: var(--color-widget-bg, #fff);
288
+ }
289
+
290
+ /* system fields aren't real contact fields - dashed and muted */
291
+ .system-pill {
292
+ display: inline-flex;
293
+ align-items: center;
294
+ gap: 5px;
295
+ font-size: 0.85em;
296
+ line-height: 1.5;
297
+ padding: 0.05em 0.65em;
298
+ border-radius: 999px;
299
+ white-space: nowrap;
300
+ border: 1px dashed var(--border-strong, #9ca3af);
301
+ color: var(--text-3, #7b8593);
302
+ --icon-color: var(--text-3, #7b8593);
303
+ }
304
+
305
+ .anchor-label {
306
+ display: flex;
307
+ align-items: center;
308
+ margin: 0.5em 0;
309
+ padding: 0 0.6em;
310
+ }
311
+
312
+ /* flat event entry - no card chrome */
313
+ .event {
314
+ flex-grow: 1;
315
+ min-width: 0;
316
+ display: flex;
317
+ align-items: center;
318
+ gap: 12px;
319
+ padding: 0.4em 0.75em;
320
+ margin: 0.1em 0;
321
+ border-radius: var(--curvature);
322
+ /* transparent by default so the hover border doesn't shift layout */
323
+ border: 1px solid transparent;
324
+ }
325
+
326
+ .count {
327
+ flex-shrink: 0;
328
+ margin-left: auto;
329
+ display: flex;
330
+ align-items: center;
331
+ gap: 0.35em;
332
+ font-size: 0.8em;
333
+ color: var(--text-3, #7b8593);
334
+ white-space: nowrap;
335
+ --icon-color: var(--text-3, #7b8593);
336
+ }
337
+
338
+ .event.clickable {
339
+ cursor: pointer;
340
+ }
341
+
342
+ .event.clickable:hover {
343
+ border-color: var(--border, #e4e7ec);
344
+ }
345
+
346
+ .title {
347
+ flex: 1;
348
+ min-width: 0;
349
+ display: flex;
350
+ align-items: center;
351
+ gap: 0.45em;
352
+ color: var(--text-color);
353
+ line-height: 1.4;
354
+ }
355
+
356
+ .title-text {
357
+ flex: 1;
358
+ min-width: 0;
359
+ display: -webkit-box;
360
+ -webkit-line-clamp: 2;
361
+ -webkit-box-orient: vertical;
362
+ overflow: hidden;
363
+ }
364
+
365
+ /* the detail modal stands in for an event read page - page-like, no
366
+ colored header bar. The header and gutter pin to the top and bottom
367
+ with full-bleed rules; the body between them scrolls when needed. */
368
+ .detail {
369
+ display: flex;
370
+ flex-direction: column;
371
+ }
372
+
373
+ .detail-body {
374
+ display: flex;
375
+ flex-direction: column;
376
+ gap: 1.25em;
377
+ overflow-y: auto;
378
+ max-height: calc(100vh - 300px);
379
+ padding: 1.25em 1.75em;
380
+ }
381
+
382
+ /* header and gutter share uniform padding on all sides, with the
383
+ title vertically centered against the action buttons */
384
+ .detail-header {
385
+ display: flex;
386
+ align-items: center;
387
+ gap: 12px;
388
+ padding: 1em;
389
+ border-bottom: 1px solid var(--border, #e4e7ec);
390
+ }
391
+
392
+ .detail-title {
393
+ flex: 1;
394
+ min-width: 0;
395
+ display: flex;
396
+ flex-direction: column;
397
+ gap: 4px;
398
+ }
399
+
400
+ .detail-campaign {
401
+ font-size: 15.5px;
402
+ font-weight: var(--w-semibold, 600);
403
+ color: var(--text-1);
404
+ white-space: nowrap;
405
+ overflow: hidden;
406
+ text-overflow: ellipsis;
407
+ }
408
+
409
+ /* the schedule line leads the body - the offset, anchor field and
410
+ delivery hour on the left, the labeled scheduled count on the right */
411
+ .detail-schedule-row {
412
+ display: flex;
413
+ align-items: center;
414
+ justify-content: space-between;
415
+ gap: 12px;
416
+ }
417
+
418
+ .detail-schedule {
419
+ display: flex;
420
+ align-items: center;
421
+ gap: 8px;
422
+ font-size: 0.9em;
423
+ color: var(--text-3, #7b8593);
424
+ }
425
+
426
+ .detail-count {
427
+ flex-shrink: 0;
428
+ display: flex;
429
+ align-items: center;
430
+ gap: 0.35em;
431
+ font-size: 0.85em;
432
+ color: var(--text-3, #7b8593);
433
+ --icon-color: var(--text-3, #7b8593);
434
+ }
435
+
436
+ .detail-actions {
437
+ flex-shrink: 0;
438
+ display: flex;
439
+ align-items: center;
440
+ gap: 8px;
441
+ }
442
+
443
+ /* match the page header's content-menu buttons so the modal's actions
444
+ read like page actions */
445
+ .menu-button {
446
+ display: inline-flex;
447
+ align-items: center;
448
+ gap: 6px;
449
+ box-sizing: border-box;
450
+ height: 26px;
451
+ padding: 0 10px;
452
+ border: 1px solid var(--border-strong, #9ca3af);
453
+ border-radius: var(--r-sm);
454
+ font-size: 12.5px;
455
+ font-weight: var(--w-regular, 400);
456
+ cursor: pointer;
457
+ user-select: none;
458
+ background: var(--surface);
459
+ color: var(--text-1);
460
+ white-space: nowrap;
461
+ }
462
+
463
+ .menu-button:hover {
464
+ background: var(--sunken);
465
+ }
466
+
467
+ .menu-button.destructive {
468
+ color: #dc2626;
469
+ }
470
+
471
+ .menu-button.destructive:hover {
472
+ border-color: #dc2626;
473
+ background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
474
+ }
475
+
476
+ /* the event's action, contained with a leading type label. Children
477
+ hug their content so a flow pill doesn't stretch the box's width. */
478
+ .detail-action {
479
+ display: flex;
480
+ flex-direction: column;
481
+ align-items: flex-start;
482
+ gap: 0.6em;
483
+ padding: 0.9em 1em 1em;
484
+ background: color-mix(
485
+ in srgb,
486
+ var(--sunken, #f1f3f5) 45%,
487
+ var(--surface, #fff)
488
+ );
489
+ border: 1px solid var(--border, #e4e7ec);
490
+ border-radius: var(--r);
491
+ line-height: 1.5;
492
+ }
493
+
494
+ /* the close (✕) in the header's upper right - the square
495
+ hover-wash button treatment (the pager buttons'), sitting
496
+ just outboard of the action buttons */
497
+ .detail-close {
498
+ flex-shrink: 0;
499
+ margin-left: -4px;
500
+ display: inline-flex;
501
+ align-items: center;
502
+ justify-content: center;
503
+ width: 28px;
504
+ height: 28px;
505
+ border-radius: var(--r-sm);
506
+ color: var(--text-3, #7b8593);
507
+ cursor: pointer;
508
+ user-select: none;
509
+ }
510
+ .detail-close:hover {
511
+ background: var(--sunken);
512
+ color: var(--text-1);
513
+ }
514
+ .detail-close temba-icon {
515
+ --icon-color: currentColor;
516
+ }
517
+
518
+ .detail-section-title {
519
+ margin-top: 0.25em;
520
+ font-size: 0.75em;
521
+ font-weight: 600;
522
+ text-transform: uppercase;
523
+ letter-spacing: 0.05em;
524
+ color: var(--text-3, #7b8593);
525
+ }
526
+
527
+ /* recent contacts - a simple name/time table */
528
+ .fires {
529
+ min-height: 2em;
530
+ margin: -0.25em 0 0.5em;
531
+ }
532
+
533
+ .fire-row {
534
+ display: flex;
535
+ align-items: center;
536
+ justify-content: space-between;
537
+ gap: 1em;
538
+ padding: 0.4em 0.5em;
539
+ border-radius: var(--r-sm);
540
+ cursor: pointer;
541
+ }
542
+
543
+ .fire-row:hover {
544
+ background: var(--color-selection, rgba(0, 0, 0, 0.04));
545
+ }
546
+
547
+ .fire-name {
548
+ min-width: 0;
549
+ overflow: hidden;
550
+ text-overflow: ellipsis;
551
+ white-space: nowrap;
552
+ }
553
+
554
+ .fire-row temba-date {
555
+ flex-shrink: 0;
556
+ font-size: 0.85em;
557
+ color: var(--text-3, #7b8593);
558
+ }
559
+ `;
560
+ }
561
+
562
+ // the endpoint returns a custom {events: [...]} payload rather than the
563
+ // paginated {results: [...]} shape that the store's fetch machinery
564
+ // expects, so we fetch and assign data ourselves. The base class's
565
+ // inherited `url` property is unsupported here - setting it would race
566
+ // its monitored fetch against ours.
567
+ private getEndpoint(): string | null {
568
+ if (this.endpoint) {
569
+ return this.endpoint;
570
+ }
571
+ return this.campaign
572
+ ? `/campaign/events/${encodeURIComponent(this.campaign)}/`
573
+ : null;
574
+ }
575
+
576
+ private async loadEvents(): Promise<void> {
577
+ // capture the endpoint at request time so a slower in-flight response for
578
+ // a previous campaign can't overwrite data for the one we're now showing
579
+ const requestedEndpoint = this.getEndpoint();
580
+ if (!requestedEndpoint) {
581
+ this.data = null;
582
+ return;
583
+ }
584
+
585
+ try {
586
+ const response = await this.store.getUrl(requestedEndpoint, {
587
+ force: true
588
+ });
589
+ if (this.getEndpoint() !== requestedEndpoint) {
590
+ return;
591
+ }
592
+ this.data = response.json;
593
+ } catch {
594
+ // on failure leave the prior data in place so a transient blip doesn't
595
+ // wipe the schedule; data is only cleared when the campaign changes
596
+ }
597
+ }
598
+
599
+ public refresh(): void {
600
+ this.loadEvents();
601
+ }
602
+
603
+ protected updated(
604
+ changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
605
+ ): void {
606
+ super.updated(changes);
607
+
608
+ if (changes.has('campaign') || changes.has('endpoint')) {
609
+ // blank immediately on a campaign switch so the previous campaign's
610
+ // events aren't briefly visible while the new fetch is in flight
611
+ this.data = null;
612
+ this.loadEvents();
613
+ }
614
+
615
+ if (changes.has('data') && this.data) {
616
+ const events = Array.isArray(this.data.events) ? this.data.events : [];
617
+ this.fireCustomEvent(CustomEventType.DetailsChanged, {
618
+ count: events.length
619
+ });
620
+ }
621
+ }
622
+
623
+ // events arrive sorted by field name then offset; group them into one
624
+ // section per field, split around the field's date
625
+ private getSections(events: CampaignScheduleEvent[]): Section[] {
626
+ const sections: Section[] = [];
627
+ const byKey: { [key: string]: Section } = {};
628
+
629
+ for (const event of events) {
630
+ const key = event.relative_to?.key || '';
631
+ let section = byKey[key];
632
+ if (!section) {
633
+ section = {
634
+ key,
635
+ name: event.relative_to?.name || '',
636
+ system: !!event.relative_to?.system,
637
+ before: [],
638
+ onAfter: []
639
+ };
640
+ byKey[key] = section;
641
+ sections.push(section);
642
+ }
643
+ if (event.offset < 0) {
644
+ section.before.push(event);
645
+ } else {
646
+ section.onAfter.push(event);
647
+ }
648
+ }
649
+ return sections;
650
+ }
651
+
652
+ // whether this event can be edited - scheduling-state events are locked
653
+ // until mailroom finishes rescheduling them
654
+ private canEdit(event: CampaignScheduleEvent): boolean {
655
+ return !!this.data?.can_edit && event.status === 'ready';
656
+ }
657
+
658
+ private canDelete(): boolean {
659
+ return !!this.data?.can_delete;
660
+ }
661
+
662
+ // in place of an event read page, clicking an event opens its detail
663
+ // modal, which also loads who the event recently fired for
664
+ public handleEventClicked(event: CampaignScheduleEvent) {
665
+ this.detailEvent = event;
666
+ this.loadFires(event);
667
+ }
668
+
669
+ private async loadFires(event: CampaignScheduleEvent): Promise<void> {
670
+ this.fires = null;
671
+ try {
672
+ const response = await this.store.getUrl(event.fires_url, {
673
+ force: true
674
+ });
675
+ if (this.detailEvent !== event) {
676
+ return;
677
+ }
678
+ this.fires = Array.isArray(response.json?.fires)
679
+ ? response.json.fires
680
+ : [];
681
+ } catch {
682
+ if (this.detailEvent === event) {
683
+ this.fires = [];
684
+ }
685
+ }
686
+ }
687
+
688
+ // edit / delete close the detail modal before the host opens the edit or
689
+ // delete-confirm modal in its place - modals never stack
690
+ public handleEditClicked() {
691
+ const event = this.detailEvent;
692
+ this.detailEvent = null;
693
+ this.fireCustomEvent(CustomEventType.Selection, {
694
+ action: 'edit_event',
695
+ event
696
+ });
697
+ }
698
+
699
+ public handleDeleteClicked() {
700
+ const event = this.detailEvent;
701
+ this.detailEvent = null;
702
+ this.fireCustomEvent(CustomEventType.Selection, {
703
+ action: 'delete_event',
704
+ event
705
+ });
706
+ }
707
+
708
+ // clicking a flow pill navigates to the flow instead of opening the row's
709
+ // detail modal (and closes the modal when clicked from inside it)
710
+ public handlePillClicked(e: Event, event: CampaignScheduleEvent) {
711
+ e.stopPropagation();
712
+ this.detailEvent = null;
713
+ this.fireCustomEvent(CustomEventType.Selection, event.flow);
714
+ }
715
+
716
+ // navigating to a contact from the detail modal closes it first
717
+ public handleFireClicked(fire: RecentFire) {
718
+ this.detailEvent = null;
719
+ this.fireCustomEvent(CustomEventType.Selection, fire.contact);
720
+ }
721
+
722
+ // the dialog closes itself on its Close button / ESC (firing only
723
+ // temba-button-clicked) and on mask clicks (temba-dialog-hidden) - our
724
+ // open state must reset on every path or the next open is a no-op change
725
+ // and the modal never reopens
726
+ private handleDetailClosed = () => {
727
+ this.detailEvent = null;
728
+ };
729
+
730
+ // activate a non-button row on Enter/Space (matches native button behavior)
731
+ private handleActivationKey(e: KeyboardEvent, action: () => void) {
732
+ if (e.key === 'Enter' || e.key === ' ') {
733
+ e.preventDefault();
734
+ action();
735
+ }
736
+ }
737
+
738
+ // system fields (Created On, Last Seen On, ...) aren't editable contact
739
+ // fields, so they get a dashed, muted pill instead of the field pill
740
+ private renderFieldPill(field: {
741
+ name?: string;
742
+ system?: boolean;
743
+ }): TemplateResult {
744
+ if (field?.system) {
745
+ return html`<span class="system-pill">
746
+ <temba-icon name="fields" size="0.9"></temba-icon>
747
+ ${field?.name}
748
+ </span>`;
749
+ }
750
+ return html`<temba-label type="field" icon="fields"
751
+ >${field?.name}</temba-label
752
+ >`;
753
+ }
754
+
755
+ private renderTime(event: CampaignScheduleEvent): TemplateResult {
756
+ // when the event fires at a specific hour, surface it on hover
757
+ if (event.delivery_hour_display) {
758
+ return html`
759
+ <temba-tip text=${event.delivery_hour_display} position="top">
760
+ ${event.offset_display}
761
+ </temba-tip>
762
+ `;
763
+ }
764
+ return html`${event.offset_display}`;
765
+ }
766
+
767
+ // the scheduled count (or scheduling state), shown inside the event's
768
+ // selectable outline and on the detail modal
769
+ private renderCount(event: CampaignScheduleEvent): TemplateResult {
770
+ if (event.status === 'scheduling') {
771
+ return html`<div class="count">${this.lang_scheduling}</div>`;
772
+ }
773
+ return html`
774
+ <div class="count" title=${this.lang_scheduled_contacts}>
775
+ ${(event.count || 0).toLocaleString()}
776
+ <temba-icon name=${Icon.contact} size="0.9"></temba-icon>
777
+ </div>
778
+ `;
779
+ }
780
+
781
+ private renderEvent(event: CampaignScheduleEvent): TemplateResult {
782
+ // message events display the message text; flow events show a clickable
783
+ // flow pill linking to the flow (the "start" action is implied)
784
+ const body =
785
+ event.type === 'message'
786
+ ? html`<div class="title-text">
787
+ <temba-expression-highlight
788
+ >${event.message}</temba-expression-highlight
789
+ >
790
+ </div>`
791
+ : event.flow
792
+ ? html`<temba-label
793
+ type="flow"
794
+ clickable
795
+ @click=${(e: Event) => this.handlePillClicked(e, event)}
796
+ >${event.flow.name}</temba-label
797
+ >`
798
+ : html``;
799
+
800
+ // in place of an event read page, opening a row shows its detail modal
801
+ return html`
802
+ <div
803
+ class="event clickable"
804
+ role="button"
805
+ tabindex="0"
806
+ @click=${() => this.handleEventClicked(event)}
807
+ @keydown=${(e: KeyboardEvent) =>
808
+ this.handleActivationKey(e, () => this.handleEventClicked(event))}
809
+ >
810
+ <div class="title">${body}</div>
811
+ ${this.renderCount(event)}
812
+ </div>
813
+ `;
814
+ }
815
+
816
+ private renderEventRow(
817
+ event: CampaignScheduleEvent,
818
+ color: string,
819
+ first: boolean,
820
+ last: boolean
821
+ ): TemplateResult {
822
+ return html`
823
+ <div class="row">
824
+ <div class="time">${this.renderTime(event)}</div>
825
+ <div class="track">
826
+ <div class="line ${first ? 'hidden' : ''}"></div>
827
+ <div class="dot" style="--dot-color:${color}">
828
+ <temba-icon
829
+ name=${event.type === 'message' ? Icon.message : Icon.flow}
830
+ size="0.65"
831
+ ></temba-icon>
832
+ </div>
833
+ <div class="line ${last ? 'hidden' : ''}"></div>
834
+ </div>
835
+ ${this.renderEvent(event)}
836
+ </div>
837
+ `;
838
+ }
839
+
840
+ // the anchor row marks the field's date itself - events before it sit
841
+ // above, events on or after it sit below
842
+ private renderAnchorRow(
843
+ section: Section,
844
+ color: string,
845
+ first: boolean,
846
+ last: boolean
847
+ ): TemplateResult {
848
+ return html`
849
+ <div class="row anchor-row">
850
+ <div class="time"></div>
851
+ <div class="track">
852
+ <div class="line ${first ? 'hidden' : ''}"></div>
853
+ <div class="dot anchor" style="--dot-color:${color}"></div>
854
+ <div class="line ${last ? 'hidden' : ''}"></div>
855
+ </div>
856
+ <div class="anchor-label">${this.renderFieldPill(section)}</div>
857
+ </div>
858
+ `;
859
+ }
860
+
861
+ // each field's line takes its hue from the section's position, so colors
862
+ // are a pure function of the current payload
863
+ private renderSection(section: Section, index: number): TemplateResult {
864
+ const color = FIELD_COLORS[index % FIELD_COLORS.length];
865
+ const total = section.before.length + 1 + section.onAfter.length;
866
+
867
+ let idx = 0;
868
+ const rows: TemplateResult[] = [];
869
+ for (const event of section.before) {
870
+ rows.push(this.renderEventRow(event, color, idx === 0, false));
871
+ idx++;
872
+ }
873
+ rows.push(
874
+ this.renderAnchorRow(section, color, idx === 0, idx === total - 1)
875
+ );
876
+ idx++;
877
+ for (const event of section.onAfter) {
878
+ rows.push(this.renderEventRow(event, color, false, idx === total - 1));
879
+ idx++;
880
+ }
881
+
882
+ return html`<div class="panel section">${rows}</div>`;
883
+ }
884
+
885
+ // the page header — title + content menu — is temba-page-header, the
886
+ // same header the content lists embed, so a list page and this read
887
+ // page share one header treatment. Slot presence is probed at render
888
+ // time (not reactive) - fine while hosts slot content declaratively.
889
+ private renderHeader(): TemplateResult | null {
890
+ const hasSubtitle =
891
+ this.subtitle || this.querySelector('[slot="subtitle"]');
892
+ if (!this.headerTitle && !this.contentMenuEndpoint && !hasSubtitle) {
893
+ return null;
894
+ }
895
+ return html`
896
+ <div class="header-panel">
897
+ <temba-page-header content-menu-endpoint=${this.contentMenuEndpoint}>
898
+ <slot name="title" slot="title">${this.headerTitle}</slot>
899
+ ${hasSubtitle
900
+ ? html`<slot name="subtitle" slot="subtitle"
901
+ >${this.subtitle}</slot
902
+ >`
903
+ : null}
904
+ </temba-page-header>
905
+ </div>
906
+ `;
907
+ }
908
+
909
+ private renderSchedule(): TemplateResult {
910
+ if (!this.data) {
911
+ return html``;
912
+ }
913
+
914
+ // tolerate a malformed/empty response rather than throwing
915
+ const events = Array.isArray(this.data.events) ? this.data.events : [];
916
+
917
+ if (events.length === 0) {
918
+ return html`<div class="content">
919
+ <div class="panel">
920
+ <div class="empty">
921
+ <slot name="empty">
922
+ <temba-icon name=${Icon.campaign} size="2"></temba-icon>
923
+ <div class="empty-title">${this.lang_empty}</div>
924
+ <div class="empty-help">${this.lang_empty_help}</div>
925
+ </slot>
926
+ </div>
927
+ </div>
928
+ </div>`;
929
+ }
930
+
931
+ // each field's line is its own card
932
+ return html`
933
+ <div class="content">
934
+ <div class="events">
935
+ ${this.getSections(events).map((section, index) =>
936
+ this.renderSection(section, index)
937
+ )}
938
+ </div>
939
+ </div>
940
+ `;
941
+ }
942
+
943
+ // badges (the campaign's group, archived state) get their own row
944
+ // between the header and the cards. Same render-time slot probe as the
945
+ // header - late-slotted badges won't toggle the row.
946
+ private renderBadges(): TemplateResult | null {
947
+ if (!this.querySelector('[slot="badges"]')) {
948
+ return null;
949
+ }
950
+ return html`<div class="badges"><slot name="badges"></slot></div>`;
951
+ }
952
+
953
+ // the recent contacts section - omitted entirely when the event hasn't
954
+ // fired for anyone
955
+ private renderFires(): TemplateResult | null {
956
+ if (!this.fires || this.fires.length === 0) {
957
+ return null;
958
+ }
959
+ return html`
960
+ <div class="detail-section-title">${this.lang_recent_contacts}</div>
961
+ <div class="fires">
962
+ ${this.fires.map(
963
+ (fire) => html`
964
+ <div
965
+ class="fire-row"
966
+ role="button"
967
+ tabindex="0"
968
+ @click=${() => this.handleFireClicked(fire)}
969
+ @keydown=${(e: KeyboardEvent) =>
970
+ this.handleActivationKey(e, () => this.handleFireClicked(fire))}
971
+ >
972
+ <div class="fire-name">${fire.contact.name}</div>
973
+ <temba-date value=${fire.time} display="timedate"></temba-date>
974
+ </div>
975
+ `
976
+ )}
977
+ </div>
978
+ `;
979
+ }
980
+
981
+ // modal detail view for an event, standing in for an event read page -
982
+ // page-like (no colored header bar) with the schedule as its title, edit
983
+ // and delete actions, the content, and the recent contacts
984
+ private renderDetail(): TemplateResult {
985
+ const event = this.detailEvent;
986
+
987
+ return html`
988
+ <temba-dialog
989
+ size="xlarge"
990
+ variant="flat"
991
+ primaryButtonName=""
992
+ cancelButtonName=""
993
+ hideOnClick
994
+ ?open=${!!event}
995
+ @temba-dialog-hidden=${this.handleDetailClosed}
996
+ @keyup=${(e: KeyboardEvent) => {
997
+ if (e.key === 'Escape') {
998
+ this.handleDetailClosed();
999
+ }
1000
+ }}
1001
+ >
1002
+ ${event
1003
+ ? html`
1004
+ <div class="detail">
1005
+ <div class="detail-header">
1006
+ <div class="detail-title">
1007
+ <div class="detail-campaign">
1008
+ ${this.data?.campaign?.name || this.headerTitle}
1009
+ </div>
1010
+ </div>
1011
+ ${this.canEdit(event) || this.canDelete()
1012
+ ? html`<div class="detail-actions">
1013
+ ${this.canEdit(event)
1014
+ ? html`<button
1015
+ class="menu-button"
1016
+ @click=${this.handleEditClicked}
1017
+ >
1018
+ ${this.lang_edit}
1019
+ </button>`
1020
+ : null}
1021
+ ${this.canDelete()
1022
+ ? html`<button
1023
+ class="menu-button destructive"
1024
+ @click=${this.handleDeleteClicked}
1025
+ >
1026
+ ${this.lang_delete}
1027
+ </button>`
1028
+ : null}
1029
+ </div>`
1030
+ : null}
1031
+ <div
1032
+ class="detail-close"
1033
+ role="button"
1034
+ tabindex="0"
1035
+ aria-label="Close"
1036
+ @click=${this.handleDetailClosed}
1037
+ @keydown=${(e: KeyboardEvent) =>
1038
+ this.handleActivationKey(e, this.handleDetailClosed)}
1039
+ >
1040
+ <temba-icon name=${Icon.close} size="1.4"></temba-icon>
1041
+ </div>
1042
+ </div>
1043
+
1044
+ <div class="detail-body">
1045
+ <div class="detail-schedule-row">
1046
+ <div class="detail-schedule">
1047
+ <span
1048
+ >${event.offset === 0
1049
+ ? capitalize(event.offset_display)
1050
+ : event.offset_display}</span
1051
+ >
1052
+ ${this.renderFieldPill(event.relative_to)}
1053
+ ${event.delivery_hour_display
1054
+ ? html`<span>${event.delivery_hour_display}</span>`
1055
+ : null}
1056
+ </div>
1057
+ <div
1058
+ class="detail-count"
1059
+ title=${event.status === 'scheduling'
1060
+ ? ''
1061
+ : this.lang_scheduled_contacts}
1062
+ >
1063
+ ${event.status === 'scheduling'
1064
+ ? html`${this.lang_scheduling}`
1065
+ : html`<temba-icon
1066
+ name=${Icon.contact}
1067
+ size="0.9"
1068
+ ></temba-icon>
1069
+ ${(event.count || 0).toLocaleString()}
1070
+ ${this.lang_scheduled}`}
1071
+ </div>
1072
+ </div>
1073
+
1074
+ <div class="detail-action">
1075
+ <div class="detail-section-title">
1076
+ ${event.type === 'message'
1077
+ ? this.lang_send_message
1078
+ : this.lang_start_flow}
1079
+ </div>
1080
+ ${event.type === 'message'
1081
+ ? html`<temba-expression-highlight
1082
+ >${event.message}</temba-expression-highlight
1083
+ >`
1084
+ : event.flow
1085
+ ? html`<temba-label
1086
+ type="flow"
1087
+ clickable
1088
+ @click=${(e: Event) =>
1089
+ this.handlePillClicked(e, event)}
1090
+ >${event.flow.name}</temba-label
1091
+ >`
1092
+ : null}
1093
+ </div>
1094
+
1095
+ ${this.renderFires()}
1096
+ </div>
1097
+ </div>
1098
+ `
1099
+ : null}
1100
+ </temba-dialog>
1101
+ `;
1102
+ }
1103
+
1104
+ public render(): TemplateResult {
1105
+ return html`${this.renderHeader()} ${this.renderBadges()}
1106
+ ${this.renderSchedule()} ${this.renderDetail()}`;
1107
+ }
1108
+ }