feat: scheduled message shortcuts (#238)
* feat(scheduled-messages): add predefined time shortcuts for scheduling
Replace the manual date/time picker with predefined day and time period
chip selectors for faster message scheduling.
Day shortcuts: Today, Tomorrow, This weekend (Sat), Next week (Mon),
Next weekend (next Sat), Next month (1st), Custom (date picker fallback).
Time period shortcuts: Morning (8:00), Afternoon (13:00), Evening (18:00).
Each day chip shows the corresponding date (dd/MM) in secondary color.
Past time periods for 'Today' are automatically disabled.
The existing date picker is preserved as the 'Custom' option.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(scheduled-messages): use dropdown selectors and remove header
Replace chip/button selectors with native <select> dropdowns for day
and time period selection. Remove the 'Date and time to send' header
from the modal since the dropdown labels serve as placeholders.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): close datepicker on confirm and click-outside
Removed manual open/close state management and @click.stop that was
blocking click-outside detection. Added confirm prop so the picker
has an explicit OK button and auto-closes properly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): initialize custom mode when editing existing schedule
When editing a scheduled message, the ScheduleDateShortcuts component
now detects the pre-existing datetime and opens in Custom mode with
the datepicker pre-filled, preserving the original date and time.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): use locale-aware date format in shortcuts
Replace hardcoded dd/MM format with Intl.DateTimeFormat using
navigator.language, matching the existing locale-aware pattern
in DatePickerHelper.js. Removes unused date-fns format import.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): use app locale for date formatting
Replace navigator.language with the i18n app locale for shortcut date
labels and datepicker calendar. Add getDatePickerLang helper that
generates locale-aware day/month names via Intl.DateTimeFormat.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): normalize locale tag to BCP 47 format
Chatwoot uses underscore locale tags (pt_BR) but Intl.DateTimeFormat
requires BCP 47 hyphens (pt-BR). Add toBcp47 normalizer to prevent
RangeError: invalid language tag.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): locale-aware time period display
Add formatHour helper using Intl.DateTimeFormat so time periods
show 8:00/13:00/18:00 in pt-BR and 8:00 AM/1:00 PM/6:00 PM in en.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test(scheduled-messages): add unit tests for scheduleDateShortcutHelpers
Cover getShortcutDate (weekday/Saturday/Sunday edge cases),
applyTimePeriod, isTimePeriodPast, formatShortDate, formatHour,
getDatePickerLang, and getDayShortcutOptions including locale
normalization for underscore tags like pt_BR.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): sync customDateTime when modelValue changes in custom mode
Keep DatePicker in sync when the parent changes modelValue while
already in Custom mode (e.g. switching between scheduled messages).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): add aria-labels to schedule dropdown selects
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(scheduled-messages): use empty string instead of null for dateTimeError
Avoids Vue prop validation warning since ScheduleDateShortcuts
declares dateTimeError as a String prop.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(scheduled-messages): simplify to 3 fixed schedule shortcuts
Replace two dropdown selectors (6 day options × 3 time periods) with
3 pre-computed clickable shortcut buttons:
- Tomorrow morning (08:00)
- Tomorrow afternoon (13:00)
- Monday morning (08:00)
Each shortcut shows the exact calendar date and time for clarity.
Special Sunday rule: 'Monday' points to next week's Monday since
'Tomorrow' already covers the immediate Monday.
The 'Custom' option with full DatePicker is preserved.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(scheduled-messages): Gmail-style list design for schedule shortcuts
Replace chip buttons with full-width clickable rows in a bordered
container, matching Gmail's 'Schedule send' dialog pattern:
- Label on left, formatted date/time on right in gray
- Calendar icon for the custom date/time option
- Subtle border separators between rows
- Selected state with blue highlight
Also improves date formatting from '15/03' to '15 de mar.' using
month short names for better readability.
Updates i18n:
- PT_BR: 'Amanhã à tarde', 'Escolher data e hora'
- EN: 'Choose date and time'
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(scheduled-messages): improve modal layout and datepicker positioning
- Add 'Schedule send' / 'Programar envio' section header above the
schedule shortcuts for clear visual identification
- Move attachment/template controls next to the message editor so
content-related actions stay grouped together
- Move datepicker outside the bordered shortcut container to avoid
cramped positioning; add rounded-xl and proper text sizing
- Add i18n key SCHEDULE_LABEL (en: 'Schedule send', pt_BR: 'Programar envio')
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* refactor(schedule): replace popup datepicker with inline calendar
Replace the popup-style vue-datepicker-next with an inline calendar
(same approach used by the snooze feature), rendering the calendar
directly within the modal for a more intuitive UX.
- Switch DatePicker to inline mode (no popup/z-index issues)
- Add disablePastTimes validation (prevents selecting past times)
- Full-width responsive calendar with scoped deep styles
- Remove unused DATETIME_PLACEHOLDER and DATETIME_FORMAT i18n keys
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(schedule): replace datepicker with natural language input
Replace the broken inline datepicker with a natural language text input
powered by chrono-node, similar to Chatwoot's upcoming snooze UX.
Users can now type dates naturally:
- EN: 'tomorrow at 2pm', 'next friday morning', 'in 3 hours'
- PT: 'amanhã às 14h', 'próxima sexta de manhã', '20 de março às 10h'
Changes:
- Add chrono-node dependency for natural language date parsing
- Add preProcessDateInput() to normalize PT time expressions (8h→8:00,
de manhã→8:00, à tarde→13:00, de noite→18:00)
- Add parseNaturalDate() with locale-aware parsing (PT/EN)
- Add formatFullDateTime() for parsed date preview display
- Replace DatePicker with text input + real-time parsed date preview
- Show green checkmark when date is valid, amber warning if in the past,
hint text if input is unrecognizable
- Remove unused getDatePickerLang() and vue-datepicker-next import
- Remove unused DATETIME_PLACEHOLDER/DATETIME_FORMAT i18n keys
- Update i18n: CUSTOM label, placeholder, hint, and past-date warning
- Update tests: 40 tests covering new functions (was 29)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(schedule): improve PT natural language preprocessing
Normalize accent-less input common in casual Brazilian Portuguese:
- 'amanha' → 'amanhã', 'sabado' → 'sábado', 'proxima' → 'próxima'
- 'as' → 'às' before digits or time-of-day words (e.g. 'as 19h')
- Support 'pela manhã/tarde/noite' and 'no período da manhã/tarde/noite'
Previously 'Amanhã as 19h' failed because chrono-node requires 'às'
(with accent) as a time connector. Now all common casual PT patterns
work reliably.
Tests: 50 passing (was 40)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(schedule): use forwardDate to always parse weekdays as upcoming
chrono-node defaults to the *most recent* occurrence of a weekday,
so 'sexta' on Monday returned last Friday (past). Adding
{ forwardDate: true } makes it always return the next occurrence.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat(schedule): add datepicker fallback and dual-language parsing
- Add discrete calendar icon button next to text input that toggles
an inline vue-datepicker-next with date+time selection
- Try both chrono.pt and chrono (EN) parsers, pick the one that
matches more of the input text — supports mixed-language input
like 'quarta 10am' or 'friday às 14h'
- Insert 'às' connector between PT weekday names and bare numbers
so 'quarta 10' parses correctly (chrono.pt requires the connector)
- Add DATEPICKER_TOOLTIP i18n key (EN + PT_BR)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(schedule): toggle between text input and datepicker views
When calendar button is clicked, hide the natural language text input
and show the inline datepicker full-width. A small 'Type a date and
time' link below the calendar lets users switch back to text input.
Calendar button is vertically centered with the input field (size-[34px]
matches input height).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(schedule): align calendar button with text input height
Use self-stretch instead of fixed size so the button stretches to
match the input height in the flex row, eliminating misalignment.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(schedule): adjust margin for custom text input in date shortcuts
* fix(schedule): use popup datepicker with datetime and confirm
Replace the inline datepicker toggle with a popup DatePicker that
opens directly from the calendar button. Uses type='datetime' with
confirm mode so users can pick both date and time. On confirm, the
selected datetime populates the natural language text field. The text
input and calendar button are always visible side by side.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(schedule): hide seconds column in datepicker
Add :show-second='false' to only show hour and minute selection.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(schedule): adjust DatePicker width for better responsiveness
* refactor(schedule): use locale for parser priority and add aria-label
- parseNaturalDate now uses locale to call the matching parser first
(chrono.pt for PT, chrono for EN) before falling back to the other,
removing the eslint-disable comment for unused locale param
- Add aria-label to the natural language date input for screen readers
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: remove leftover planning files
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: gabrieljablonski <contact@gabrieljablonski.com>
This commit is contained in:
parent
9a05ff5247
commit
a06354c6b2
199
app/javascript/dashboard/helper/scheduleDateShortcutHelpers.js
Normal file
199
app/javascript/dashboard/helper/scheduleDateShortcutHelpers.js
Normal file
@ -0,0 +1,199 @@
|
||||
import {
|
||||
getDay,
|
||||
addDays,
|
||||
setHours,
|
||||
setMinutes,
|
||||
setSeconds,
|
||||
isBefore,
|
||||
} from 'date-fns';
|
||||
import * as chrono from 'chrono-node';
|
||||
|
||||
export const SHORTCUT_KEYS = {
|
||||
TOMORROW_MORNING: 'tomorrow_morning',
|
||||
TOMORROW_AFTERNOON: 'tomorrow_afternoon',
|
||||
MONDAY_MORNING: 'monday_morning',
|
||||
CUSTOM: 'custom',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a locale tag to BCP 47 format (e.g. 'pt_BR' → 'pt-BR').
|
||||
*/
|
||||
const toBcp47 = locale => (locale || 'en').replace('_', '-');
|
||||
|
||||
/**
|
||||
* Get the date for "tomorrow" (always the next calendar day).
|
||||
*/
|
||||
export const getTomorrowDate = (now = new Date()) => {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return addDays(today, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the date for the "Monday" shortcut.
|
||||
* On Sunday, tomorrow is already Monday, so this returns next week's Monday.
|
||||
* On all other days, returns the upcoming Monday.
|
||||
*/
|
||||
export const getMondayDate = (now = new Date()) => {
|
||||
const today = new Date(now);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dayOfWeek = getDay(now); // 0=Sun, 6=Sat
|
||||
|
||||
if (dayOfWeek === 0) return addDays(today, 8);
|
||||
return addDays(today, 8 - dayOfWeek);
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply an hour to a date, returning a new Date with that hour set (minutes/seconds zeroed).
|
||||
*/
|
||||
export const applyHour = (date, hour) =>
|
||||
setSeconds(setMinutes(setHours(new Date(date), hour), 0), 0);
|
||||
|
||||
/**
|
||||
* Format an hour (0-23) as a locale-aware time string (e.g. '18:00' or '6:00 PM').
|
||||
*/
|
||||
export const formatHour = (hour, locale = 'en') => {
|
||||
const date = new Date(2023, 0, 1, hour, 0, 0);
|
||||
return new Intl.DateTimeFormat(toBcp47(locale), {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a date as a locale-aware short date with month name (e.g. '11 de mar.' / 'Mar 11').
|
||||
*/
|
||||
export const formatShortDate = (date, locale = 'en') =>
|
||||
new Intl.DateTimeFormat(toBcp47(locale), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
}).format(date);
|
||||
|
||||
/**
|
||||
* Build the 3 predefined schedule shortcuts with pre-computed dates.
|
||||
* Shortcuts whose datetime is already in the past are excluded.
|
||||
*/
|
||||
export const getScheduleShortcuts = (now = new Date(), locale = 'en') => {
|
||||
const tomorrow = getTomorrowDate(now);
|
||||
const monday = getMondayDate(now);
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
key: SHORTCUT_KEYS.TOMORROW_MORNING,
|
||||
labelI18nKey: 'SCHEDULED_MESSAGES.MODAL.SHORTCUTS.TOMORROW_MORNING',
|
||||
date: tomorrow,
|
||||
hour: 8,
|
||||
},
|
||||
{
|
||||
key: SHORTCUT_KEYS.TOMORROW_AFTERNOON,
|
||||
labelI18nKey: 'SCHEDULED_MESSAGES.MODAL.SHORTCUTS.TOMORROW_AFTERNOON',
|
||||
date: tomorrow,
|
||||
hour: 13,
|
||||
},
|
||||
{
|
||||
key: SHORTCUT_KEYS.MONDAY_MORNING,
|
||||
labelI18nKey: 'SCHEDULED_MESSAGES.MODAL.SHORTCUTS.MONDAY_MORNING',
|
||||
date: monday,
|
||||
hour: 8,
|
||||
},
|
||||
];
|
||||
|
||||
return shortcuts
|
||||
.map(s => {
|
||||
const dateTime = applyHour(s.date, s.hour);
|
||||
const formattedDate = formatShortDate(s.date, locale);
|
||||
const formattedTime = formatHour(s.hour, locale);
|
||||
return {
|
||||
...s,
|
||||
dateTime,
|
||||
formattedDate,
|
||||
formattedTime,
|
||||
detail: `${formattedDate}, ${formattedTime}`,
|
||||
};
|
||||
})
|
||||
.filter(s => !isBefore(s.dateTime, now));
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-process natural language input to normalize PT/EN time expressions
|
||||
* before passing to chrono-node.
|
||||
*/
|
||||
export const preProcessDateInput = text => {
|
||||
let result = text;
|
||||
// PT: normalize common words typed without accents
|
||||
result = result.replace(/\bamanha\b/gi, 'amanhã');
|
||||
result = result.replace(/\bsabado\b/gi, 'sábado');
|
||||
result = result.replace(/\bproxim([ao])\b/gi, 'próxim$1');
|
||||
// PT: normalize 'as' → 'às' before digits or time-of-day words
|
||||
result = result.replace(/\bas\s+(\d)/gi, 'às $1');
|
||||
result = result.replace(/\bas\s+(manh|tard|noit)/gi, 'às $1');
|
||||
// PT: insert 'às' connector between weekday name and bare number/time
|
||||
// chrono.pt needs 'às' to link weekday + time (e.g. "quarta 10" → "quarta às 10")
|
||||
const ptWeekdays =
|
||||
'(?:segunda(?:-feira)?|ter[çc]a(?:-feira)?|quarta(?:-feira)?|quinta(?:-feira)?|sexta(?:-feira)?|s[áa]bado|domingo)';
|
||||
result = result.replace(
|
||||
new RegExp(`(${ptWeekdays})\\s+(?!às|as)(\\d)`, 'gi'),
|
||||
'$1 às $2'
|
||||
);
|
||||
// PT: 'Xh' or 'XhMM' → 'X:00' or 'X:MM' (e.g. '14h' → '14:00', '14h30' → '14:30')
|
||||
result = result.replace(
|
||||
/(\d{1,2})h(\d{2})?(?=\s|$|,)/gi,
|
||||
(_, h, min) => `${h}:${min || '00'}`
|
||||
);
|
||||
// PT: time-of-day expressions (de manhã, pela tarde, no período da noite, etc.)
|
||||
result = result.replace(
|
||||
/(?:no per[ií]odo da|pela|de|à|às)\s+manh[ãa]/gi,
|
||||
'8:00'
|
||||
);
|
||||
result = result.replace(
|
||||
/(?:no per[ií]odo da|pela|de|à|às)\s+tarde/gi,
|
||||
'13:00'
|
||||
);
|
||||
result = result.replace(
|
||||
/(?:no per[ií]odo da|pela|de|à|às)\s+noite/gi,
|
||||
'18:00'
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a natural language date/time string using chrono-node.
|
||||
* Supports both PT and EN locales.
|
||||
* Returns a Date object if successfully parsed, otherwise null.
|
||||
*/
|
||||
export const parseNaturalDate = (text, locale = 'en', now = new Date()) => {
|
||||
if (!text || !text.trim()) return null;
|
||||
const processed = preProcessDateInput(text.trim());
|
||||
const opts = { forwardDate: true };
|
||||
const isPt = locale.startsWith('pt');
|
||||
const primaryResults = (isPt ? chrono.pt : chrono).parse(
|
||||
processed,
|
||||
now,
|
||||
opts
|
||||
);
|
||||
const fallbackResults = (isPt ? chrono : chrono.pt).parse(
|
||||
processed,
|
||||
now,
|
||||
opts
|
||||
);
|
||||
const matchLen = results => results.reduce((s, r) => s + r.text.length, 0);
|
||||
// Pick the parser that matched more of the input text
|
||||
const best =
|
||||
matchLen(primaryResults) >= matchLen(fallbackResults)
|
||||
? primaryResults
|
||||
: fallbackResults;
|
||||
return best.length ? best[0].start.date() : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a Date as a full locale-aware date-time string for preview display.
|
||||
* e.g. '17 de mar. de 2026, 08:00' (pt-BR) or 'Mar 17, 2026, 8:00 AM' (en)
|
||||
*/
|
||||
export const formatFullDateTime = (date, locale = 'en') =>
|
||||
new Intl.DateTimeFormat(toBcp47(locale), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
@ -0,0 +1,352 @@
|
||||
import {
|
||||
SHORTCUT_KEYS,
|
||||
getTomorrowDate,
|
||||
getMondayDate,
|
||||
applyHour,
|
||||
formatShortDate,
|
||||
formatHour,
|
||||
getScheduleShortcuts,
|
||||
preProcessDateInput,
|
||||
parseNaturalDate,
|
||||
formatFullDateTime,
|
||||
} from '../scheduleDateShortcutHelpers';
|
||||
|
||||
describe('#scheduleDateShortcutHelpers', () => {
|
||||
// Wednesday 2023-06-14
|
||||
const wednesday = new Date('2023-06-14T10:30:00');
|
||||
// Saturday 2023-06-17
|
||||
const saturday = new Date('2023-06-17T10:30:00');
|
||||
// Sunday 2023-06-18
|
||||
const sunday = new Date('2023-06-18T10:30:00');
|
||||
// Monday 2023-06-19
|
||||
const monday = new Date('2023-06-19T10:30:00');
|
||||
|
||||
describe('getTomorrowDate', () => {
|
||||
it('returns the next day at midnight', () => {
|
||||
expect(getTomorrowDate(wednesday)).toEqual(
|
||||
new Date('2023-06-15T00:00:00')
|
||||
);
|
||||
});
|
||||
|
||||
it('returns Monday from Sunday', () => {
|
||||
expect(getTomorrowDate(sunday)).toEqual(new Date('2023-06-19T00:00:00'));
|
||||
});
|
||||
|
||||
it('returns Sunday from Saturday', () => {
|
||||
expect(getTomorrowDate(saturday)).toEqual(
|
||||
new Date('2023-06-18T00:00:00')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMondayDate', () => {
|
||||
it('returns next Monday from a weekday (Wednesday)', () => {
|
||||
expect(getMondayDate(wednesday)).toEqual(new Date('2023-06-19T00:00:00'));
|
||||
});
|
||||
|
||||
it('returns next Monday from Saturday', () => {
|
||||
expect(getMondayDate(saturday)).toEqual(new Date('2023-06-19T00:00:00'));
|
||||
});
|
||||
|
||||
it('returns Monday of NEXT week from Sunday (8 days away)', () => {
|
||||
// On Sunday, tomorrow is already Monday, so this returns the Monday after
|
||||
expect(getMondayDate(sunday)).toEqual(new Date('2023-06-26T00:00:00'));
|
||||
});
|
||||
|
||||
it('returns next Monday from Monday (7 days away)', () => {
|
||||
expect(getMondayDate(monday)).toEqual(new Date('2023-06-26T00:00:00'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyHour', () => {
|
||||
const baseDate = new Date('2023-06-14T00:00:00');
|
||||
|
||||
it('sets 8:00 for hour 8', () => {
|
||||
const result = applyHour(baseDate, 8);
|
||||
expect(result.getHours()).toBe(8);
|
||||
expect(result.getMinutes()).toBe(0);
|
||||
expect(result.getSeconds()).toBe(0);
|
||||
});
|
||||
|
||||
it('sets 13:00 for hour 13', () => {
|
||||
const result = applyHour(baseDate, 13);
|
||||
expect(result.getHours()).toBe(13);
|
||||
expect(result.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not mutate the original date', () => {
|
||||
const original = new Date('2023-06-14T00:00:00');
|
||||
applyHour(original, 18);
|
||||
expect(original.getHours()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatShortDate', () => {
|
||||
const date = new Date('2023-03-15T00:00:00');
|
||||
|
||||
it('formats with en locale using month name', () => {
|
||||
const result = formatShortDate(date, 'en');
|
||||
expect(result).toMatch(/Mar.*15|15.*Mar/i);
|
||||
});
|
||||
|
||||
it('handles underscore locale tags like pt_BR', () => {
|
||||
expect(() => formatShortDate(date, 'pt_BR')).not.toThrow();
|
||||
});
|
||||
|
||||
it('falls back to en for empty locale', () => {
|
||||
expect(() => formatShortDate(date, '')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatHour', () => {
|
||||
it('formats 8 for en locale with AM', () => {
|
||||
const result = formatHour(8, 'en');
|
||||
expect(result).toMatch(/8.*AM/i);
|
||||
});
|
||||
|
||||
it('formats 13 for en locale with PM', () => {
|
||||
const result = formatHour(13, 'en');
|
||||
expect(result).toMatch(/1.*PM/i);
|
||||
});
|
||||
|
||||
it('handles underscore locale tags', () => {
|
||||
expect(() => formatHour(18, 'pt_BR')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('preProcessDateInput', () => {
|
||||
it('converts PT hour format "8h" to "8:00"', () => {
|
||||
expect(preProcessDateInput('amanhã às 8h')).toBe('amanhã às 8:00');
|
||||
});
|
||||
|
||||
it('converts PT hour+min format "14h30" to "14:30"', () => {
|
||||
expect(preProcessDateInput('amanhã às 14h30')).toBe('amanhã às 14:30');
|
||||
});
|
||||
|
||||
it('converts PT "de manhã" to "8:00"', () => {
|
||||
expect(preProcessDateInput('amanhã de manhã')).toBe('amanhã 8:00');
|
||||
});
|
||||
|
||||
it('converts PT "à tarde" to "13:00"', () => {
|
||||
expect(preProcessDateInput('amanhã à tarde')).toBe('amanhã 13:00');
|
||||
});
|
||||
|
||||
it('converts PT "de noite" to "18:00"', () => {
|
||||
expect(preProcessDateInput('amanhã de noite')).toBe('amanhã 18:00');
|
||||
});
|
||||
|
||||
it('normalizes "amanha" without accent to "amanhã"', () => {
|
||||
expect(preProcessDateInput('amanha as 8h')).toBe('amanhã às 8:00');
|
||||
});
|
||||
|
||||
it('normalizes "as" to "às" before digits', () => {
|
||||
expect(preProcessDateInput('amanhã as 19h')).toBe('amanhã às 19:00');
|
||||
});
|
||||
|
||||
it('normalizes "as" to "às" before time-of-day words', () => {
|
||||
expect(preProcessDateInput('amanhã as manha')).toBe('amanhã 8:00');
|
||||
});
|
||||
|
||||
it('normalizes "sabado" to "sábado"', () => {
|
||||
expect(preProcessDateInput('sabado as 10h')).toContain('sábado');
|
||||
});
|
||||
|
||||
it('normalizes "proxima" to "próxima"', () => {
|
||||
expect(preProcessDateInput('proxima segunda')).toContain('próxima');
|
||||
});
|
||||
|
||||
it('converts "pela manhã" to "8:00"', () => {
|
||||
expect(preProcessDateInput('amanhã pela manhã')).toBe('amanhã 8:00');
|
||||
});
|
||||
|
||||
it('converts "pela tarde" to "13:00"', () => {
|
||||
expect(preProcessDateInput('amanhã pela tarde')).toBe('amanhã 13:00');
|
||||
});
|
||||
|
||||
it('converts "no período da noite" to "18:00"', () => {
|
||||
expect(preProcessDateInput('amanhã no período da noite')).toBe(
|
||||
'amanhã 18:00'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves EN text with explicit times unchanged', () => {
|
||||
expect(preProcessDateInput('tomorrow at 2pm')).toBe('tomorrow at 2pm');
|
||||
});
|
||||
|
||||
it('inserts "às" between PT weekday and bare number', () => {
|
||||
expect(preProcessDateInput('quarta 10')).toBe('quarta às 10');
|
||||
});
|
||||
|
||||
it('inserts "às" between PT weekday and am/pm time', () => {
|
||||
expect(preProcessDateInput('sexta 2pm')).toBe('sexta às 2pm');
|
||||
});
|
||||
|
||||
it('inserts "às" between PT weekday-feira and number', () => {
|
||||
expect(preProcessDateInput('quarta-feira 14h')).toBe(
|
||||
'quarta-feira às 14:00'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double-insert "às" when already present', () => {
|
||||
expect(preProcessDateInput('quarta às 10')).toBe('quarta às 10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNaturalDate', () => {
|
||||
const now = new Date('2023-06-14T10:00:00');
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(parseNaturalDate('', 'en', now)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for whitespace-only string', () => {
|
||||
expect(parseNaturalDate(' ', 'en', now)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses EN "tomorrow at 8am"', () => {
|
||||
const result = parseNaturalDate('tomorrow at 8am', 'en', now);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getDate()).toBe(15);
|
||||
expect(result.getHours()).toBe(8);
|
||||
});
|
||||
|
||||
it('parses EN "tomorrow at 1pm"', () => {
|
||||
const result = parseNaturalDate('tomorrow at 1pm', 'en', now);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getHours()).toBe(13);
|
||||
});
|
||||
|
||||
it('parses PT "amanhã às 8h" via preprocessing', () => {
|
||||
const result = parseNaturalDate('amanhã às 8h', 'pt_BR', now);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getDate()).toBe(15);
|
||||
expect(result.getHours()).toBe(8);
|
||||
});
|
||||
|
||||
it('parses PT "amanhã à tarde" via preprocessing', () => {
|
||||
const result = parseNaturalDate('amanhã à tarde', 'pt_BR', now);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getDate()).toBe(15);
|
||||
expect(result.getHours()).toBe(13);
|
||||
});
|
||||
|
||||
it('parses PT "amanha as 19h" (no accents) via preprocessing', () => {
|
||||
const result = parseNaturalDate('amanha as 19h', 'pt_BR', now);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getDate()).toBe(15);
|
||||
expect(result.getHours()).toBe(19);
|
||||
});
|
||||
|
||||
it('parses PT "amanhã pela manhã" via preprocessing', () => {
|
||||
const result = parseNaturalDate('amanhã pela manhã', 'pt_BR', now);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getDate()).toBe(15);
|
||||
expect(result.getHours()).toBe(8);
|
||||
});
|
||||
|
||||
it('parses weekday names forward (not past) — PT "sexta"', () => {
|
||||
// now is Wednesday Jun 14; "sexta" should be upcoming Friday Jun 16
|
||||
const result = parseNaturalDate('sexta', 'pt_BR', now);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getDate()).toBe(16);
|
||||
});
|
||||
|
||||
it('parses weekday names forward (not past) — EN "friday"', () => {
|
||||
const result = parseNaturalDate('friday', 'en', now);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getDate()).toBe(16);
|
||||
});
|
||||
|
||||
it('returns null for unrecognizable input', () => {
|
||||
expect(parseNaturalDate('xyz abc', 'en', now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFullDateTime', () => {
|
||||
const date = new Date('2023-03-17T08:00:00');
|
||||
|
||||
it('formats in EN locale', () => {
|
||||
const result = formatFullDateTime(date, 'en');
|
||||
expect(result).toMatch(/Mar.*17.*2023.*8.*00/i);
|
||||
});
|
||||
|
||||
it('handles pt_BR locale without errors', () => {
|
||||
expect(() => formatFullDateTime(date, 'pt_BR')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScheduleShortcuts', () => {
|
||||
it('returns 3 shortcuts on a normal weekday', () => {
|
||||
const shortcuts = getScheduleShortcuts(wednesday);
|
||||
expect(shortcuts).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns correct keys', () => {
|
||||
const shortcuts = getScheduleShortcuts(wednesday);
|
||||
const keys = shortcuts.map(s => s.key);
|
||||
expect(keys).toEqual([
|
||||
SHORTCUT_KEYS.TOMORROW_MORNING,
|
||||
SHORTCUT_KEYS.TOMORROW_AFTERNOON,
|
||||
SHORTCUT_KEYS.MONDAY_MORNING,
|
||||
]);
|
||||
});
|
||||
|
||||
it('computes correct dates for Wednesday', () => {
|
||||
const shortcuts = getScheduleShortcuts(wednesday);
|
||||
// Tomorrow = Thursday 2023-06-15
|
||||
expect(shortcuts[0].dateTime).toEqual(new Date('2023-06-15T08:00:00'));
|
||||
expect(shortcuts[1].dateTime).toEqual(new Date('2023-06-15T13:00:00'));
|
||||
// Monday = 2023-06-19
|
||||
expect(shortcuts[2].dateTime).toEqual(new Date('2023-06-19T08:00:00'));
|
||||
});
|
||||
|
||||
it('on Sunday: tomorrow is Monday, Monday shortcut is next weeks Monday', () => {
|
||||
const shortcuts = getScheduleShortcuts(sunday);
|
||||
// Tomorrow = Monday 2023-06-19
|
||||
expect(shortcuts[0].dateTime).toEqual(new Date('2023-06-19T08:00:00'));
|
||||
expect(shortcuts[1].dateTime).toEqual(new Date('2023-06-19T13:00:00'));
|
||||
// Monday = next week's Monday 2023-06-26
|
||||
expect(shortcuts[2].dateTime).toEqual(new Date('2023-06-26T08:00:00'));
|
||||
});
|
||||
|
||||
it('on Saturday: tomorrow is Sunday, Monday is the day after', () => {
|
||||
const shortcuts = getScheduleShortcuts(saturday);
|
||||
// Tomorrow = Sunday 2023-06-18
|
||||
expect(shortcuts[0].dateTime).toEqual(new Date('2023-06-18T08:00:00'));
|
||||
expect(shortcuts[1].dateTime).toEqual(new Date('2023-06-18T13:00:00'));
|
||||
// Monday = 2023-06-19
|
||||
expect(shortcuts[2].dateTime).toEqual(new Date('2023-06-19T08:00:00'));
|
||||
});
|
||||
|
||||
it('on Monday: tomorrow is Tuesday, Monday shortcut is next Monday', () => {
|
||||
const shortcuts = getScheduleShortcuts(monday);
|
||||
// Tomorrow = Tuesday 2023-06-20
|
||||
expect(shortcuts[0].dateTime).toEqual(new Date('2023-06-20T08:00:00'));
|
||||
expect(shortcuts[1].dateTime).toEqual(new Date('2023-06-20T13:00:00'));
|
||||
// Monday = 2023-06-26
|
||||
expect(shortcuts[2].dateTime).toEqual(new Date('2023-06-26T08:00:00'));
|
||||
});
|
||||
|
||||
it('includes formatted date and time', () => {
|
||||
const shortcuts = getScheduleShortcuts(wednesday, 'en');
|
||||
shortcuts.forEach(s => {
|
||||
expect(s.formattedDate).toBeTruthy();
|
||||
expect(s.formattedTime).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles pt_BR locale', () => {
|
||||
expect(() => getScheduleShortcuts(wednesday, 'pt_BR')).not.toThrow();
|
||||
});
|
||||
|
||||
it('filters out shortcuts that are in the past', () => {
|
||||
// Late Wednesday night — tomorrow morning 08:00 is still in the future
|
||||
const lateWednesday = new Date('2023-06-14T23:59:00');
|
||||
const shortcuts = getScheduleShortcuts(lateWednesday);
|
||||
expect(shortcuts.length).toBeGreaterThanOrEqual(2);
|
||||
shortcuts.forEach(s => {
|
||||
expect(s.dateTime.getTime()).toBeGreaterThan(lateWednesday.getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -446,8 +446,6 @@
|
||||
"MESSAGE_LABEL": "Message",
|
||||
"MESSAGE_PLACEHOLDER": "Write your message...",
|
||||
"DATETIME_LABEL": "Date and time to send",
|
||||
"DATETIME_PLACEHOLDER": "Select date and time",
|
||||
"DATETIME_FORMAT": "MMM D, YYYY h:mm A",
|
||||
"ATTACHMENT_LABEL": "Attachment",
|
||||
"ATTACHMENT_ADD": "Attach file",
|
||||
"ATTACHMENT_CURRENT": "Current attachment: {filename}",
|
||||
@ -456,7 +454,18 @@
|
||||
"TEMPLATE_ACTION": "Schedule message",
|
||||
"CANCEL": "Cancel",
|
||||
"SAVE_DRAFT": "Save as draft",
|
||||
"SCHEDULE": "Schedule"
|
||||
"SCHEDULE": "Schedule",
|
||||
"SHORTCUTS": {
|
||||
"TOMORROW_MORNING": "Tomorrow morning",
|
||||
"TOMORROW_AFTERNOON": "Tomorrow afternoon",
|
||||
"MONDAY_MORNING": "Monday morning",
|
||||
"CUSTOM": "Type a date and time"
|
||||
},
|
||||
"CUSTOM_INPUT_PLACEHOLDER": "e.g. tomorrow at 2pm, next friday morning",
|
||||
"CUSTOM_INPUT_HINT": "Try: tomorrow at 3pm, next monday, in 2 hours",
|
||||
"PARSED_DATE_IN_PAST": "This date is in the past",
|
||||
"DATEPICKER_TOOLTIP": "Pick from calendar",
|
||||
"SCHEDULE_LABEL": "Schedule send"
|
||||
},
|
||||
"CONFIRM_CLOSE": {
|
||||
"TITLE": "Unsaved changes",
|
||||
|
||||
@ -435,8 +435,6 @@
|
||||
"MESSAGE_LABEL": "Mensagem",
|
||||
"MESSAGE_PLACEHOLDER": "Escreva sua mensagem...",
|
||||
"DATETIME_LABEL": "Data e hora de envio",
|
||||
"DATETIME_PLACEHOLDER": "Selecione data e hora",
|
||||
"DATETIME_FORMAT": "D [de] MMM, YYYY HH:mm",
|
||||
"ATTACHMENT_LABEL": "Anexo",
|
||||
"ATTACHMENT_ADD": "Anexar arquivo",
|
||||
"ATTACHMENT_CURRENT": "Anexo atual: {filename}",
|
||||
@ -445,7 +443,18 @@
|
||||
"TEMPLATE_ACTION": "Agendar mensagem",
|
||||
"CANCEL": "Cancelar",
|
||||
"SAVE_DRAFT": "Salvar como rascunho",
|
||||
"SCHEDULE": "Agendar"
|
||||
"SCHEDULE": "Agendar",
|
||||
"SHORTCUTS": {
|
||||
"TOMORROW_MORNING": "Amanhã de manhã",
|
||||
"TOMORROW_AFTERNOON": "Amanhã à tarde",
|
||||
"MONDAY_MORNING": "Segunda-feira de manhã",
|
||||
"CUSTOM": "Digitar data e hora"
|
||||
},
|
||||
"CUSTOM_INPUT_PLACEHOLDER": "ex: amanhã às 14h, próxima sexta de manhã",
|
||||
"CUSTOM_INPUT_HINT": "Tente: amanhã às 15h, próxima segunda, 20 de março às 10h",
|
||||
"PARSED_DATE_IN_PAST": "Esta data já passou",
|
||||
"DATEPICKER_TOOLTIP": "Escolher no calendário",
|
||||
"SCHEDULE_LABEL": "Programar envio"
|
||||
},
|
||||
"CONFIRM_CLOSE": {
|
||||
"TITLE": "Alterações não salvas",
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import addDays from 'date-fns/addDays';
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
import {
|
||||
SHORTCUT_KEYS,
|
||||
getScheduleShortcuts,
|
||||
parseNaturalDate,
|
||||
formatFullDateTime,
|
||||
} from 'dashboard/helper/scheduleDateShortcutHelpers';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
dateTimeError: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const selectedKey = ref('');
|
||||
const customText = ref('');
|
||||
const parsedDate = ref(null);
|
||||
const inputRef = ref(null);
|
||||
const datePickerValue = ref(null);
|
||||
const datePickerOpen = ref(false);
|
||||
|
||||
const isCustomMode = computed(() => selectedKey.value === SHORTCUT_KEYS.CUSTOM);
|
||||
|
||||
const shortcuts = computed(() =>
|
||||
getScheduleShortcuts(new Date(), locale.value)
|
||||
);
|
||||
|
||||
const parsedPreview = computed(() => {
|
||||
if (!parsedDate.value) return '';
|
||||
return formatFullDateTime(parsedDate.value, locale.value);
|
||||
});
|
||||
|
||||
const isParsedInPast = computed(() => {
|
||||
if (!parsedDate.value) return false;
|
||||
return parsedDate.value <= new Date();
|
||||
});
|
||||
|
||||
const onSelectShortcut = shortcut => {
|
||||
selectedKey.value = shortcut.key;
|
||||
customText.value = '';
|
||||
parsedDate.value = null;
|
||||
datePickerOpen.value = false;
|
||||
emit('update:modelValue', shortcut.dateTime);
|
||||
};
|
||||
|
||||
const onSelectCustom = () => {
|
||||
selectedKey.value = SHORTCUT_KEYS.CUSTOM;
|
||||
customText.value = '';
|
||||
parsedDate.value = null;
|
||||
datePickerOpen.value = false;
|
||||
emit('update:modelValue', null);
|
||||
nextTick(() => inputRef.value?.focus());
|
||||
};
|
||||
|
||||
const onCustomInput = () => {
|
||||
const date = parseNaturalDate(customText.value, locale.value);
|
||||
parsedDate.value = date;
|
||||
emit('update:modelValue', date);
|
||||
};
|
||||
|
||||
const openDatePicker = () => {
|
||||
datePickerValue.value = parsedDate.value || null;
|
||||
datePickerOpen.value = true;
|
||||
};
|
||||
|
||||
const onDatePickerConfirm = value => {
|
||||
if (!value) return;
|
||||
parsedDate.value = value;
|
||||
customText.value = formatFullDateTime(value, locale.value);
|
||||
datePickerOpen.value = false;
|
||||
emit('update:modelValue', value);
|
||||
};
|
||||
|
||||
const disableBeforeToday = date => date < addDays(new Date(), -1);
|
||||
|
||||
const disablePastTimes = date => {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - 1);
|
||||
return date < now;
|
||||
};
|
||||
|
||||
// Sync local state when modelValue changes externally (edit mode or resetForm)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
if (!newValue) {
|
||||
if (!isCustomMode.value) {
|
||||
selectedKey.value = '';
|
||||
}
|
||||
if (!customText.value) {
|
||||
parsedDate.value = null;
|
||||
}
|
||||
} else if (!selectedKey.value) {
|
||||
selectedKey.value = SHORTCUT_KEYS.CUSTOM;
|
||||
parsedDate.value = newValue;
|
||||
customText.value = formatFullDateTime(newValue, locale.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col rounded-xl border border-n-weak bg-n-background">
|
||||
<button
|
||||
v-for="shortcut in shortcuts"
|
||||
:key="shortcut.key"
|
||||
type="button"
|
||||
class="flex items-center justify-between px-4 py-3 text-sm transition-colors border-b border-n-weak cursor-pointer first:rounded-t-xl"
|
||||
:class="
|
||||
selectedKey === shortcut.key
|
||||
? 'bg-n-alpha-2 text-n-blue-text'
|
||||
: 'text-n-slate-12 hover:bg-n-alpha-1'
|
||||
"
|
||||
@click="onSelectShortcut(shortcut)"
|
||||
>
|
||||
<span :class="{ 'font-medium': selectedKey === shortcut.key }">
|
||||
{{ t(shortcut.labelI18nKey) }}
|
||||
</span>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="
|
||||
selectedKey === shortcut.key
|
||||
? 'text-n-blue-text/70'
|
||||
: 'text-n-slate-9'
|
||||
"
|
||||
>
|
||||
{{ shortcut.detail }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-2 px-4 py-3 transition-colors rounded-b-xl"
|
||||
:class="
|
||||
isCustomMode ? 'bg-n-alpha-2' : 'cursor-pointer hover:bg-n-alpha-1'
|
||||
"
|
||||
@click="!isCustomMode && onSelectCustom()"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
class="i-lucide-keyboard size-4 shrink-0"
|
||||
:class="isCustomMode ? 'text-n-blue-text' : ''"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
isCustomMode ? 'text-n-blue-text font-medium' : 'text-n-slate-12'
|
||||
"
|
||||
>
|
||||
{{ t('SCHEDULED_MESSAGES.MODAL.SHORTCUTS.CUSTOM') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isCustomMode" class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="customText"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 !mb-0 rounded-lg border bg-n-background px-3 py-2 text-sm text-n-slate-12 placeholder:text-n-slate-9 outline-none focus:ring-1"
|
||||
:class="
|
||||
dateTimeError
|
||||
? 'border-n-ruby-9 focus:ring-n-ruby-9'
|
||||
: 'border-n-weak focus:border-n-blue-text focus:ring-n-blue-text'
|
||||
"
|
||||
:placeholder="
|
||||
t('SCHEDULED_MESSAGES.MODAL.CUSTOM_INPUT_PLACEHOLDER')
|
||||
"
|
||||
:aria-label="
|
||||
t('SCHEDULED_MESSAGES.MODAL.CUSTOM_INPUT_PLACEHOLDER')
|
||||
"
|
||||
@input="onCustomInput"
|
||||
/>
|
||||
<div class="relative shrink-0 [&_.mx-datepicker]:!w-auto">
|
||||
<DatePicker
|
||||
v-model:value="datePickerValue"
|
||||
v-model:open="datePickerOpen"
|
||||
type="datetime"
|
||||
confirm
|
||||
:clearable="false"
|
||||
:editable="false"
|
||||
:show-second="false"
|
||||
:disabled-date="disableBeforeToday"
|
||||
:disabled-time="disablePastTimes"
|
||||
:confirm-text="t('SCHEDULED_MESSAGES.MODAL.SCHEDULE')"
|
||||
append-to-body
|
||||
@confirm="onDatePickerConfirm"
|
||||
>
|
||||
<template #input>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center self-stretch rounded-lg border border-n-weak px-2 py-2 text-n-slate-9 transition-colors hover:bg-n-alpha-1 hover:text-n-slate-12"
|
||||
:title="t('SCHEDULED_MESSAGES.MODAL.DATEPICKER_TOOLTIP')"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
<span class="i-lucide-calendar size-3.5" />
|
||||
</button>
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<div
|
||||
v-if="parsedDate && !isParsedInPast"
|
||||
class="flex items-center gap-1.5 text-xs text-n-green-text"
|
||||
>
|
||||
<span class="i-lucide-check size-3.5 shrink-0" />
|
||||
<span>{{ parsedPreview }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="parsedDate && isParsedInPast"
|
||||
class="flex items-center gap-1.5 text-xs text-n-amber-text"
|
||||
>
|
||||
<span class="i-lucide-alert-triangle size-3.5 shrink-0" />
|
||||
<span>{{ t('SCHEDULED_MESSAGES.MODAL.PARSED_DATE_IN_PAST') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="customText.length > 2 && !parsedDate"
|
||||
class="flex items-center gap-1.5 text-xs text-n-slate-9"
|
||||
>
|
||||
<span class="i-lucide-help-circle size-3.5 shrink-0" />
|
||||
<span>{{ t('SCHEDULED_MESSAGES.MODAL.CUSTOM_INPUT_HINT') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@ -18,6 +17,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
import WhatsappTemplates from 'dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue';
|
||||
import ScheduleDateShortcuts from './ScheduleDateShortcuts.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@ -75,7 +75,6 @@ const existingAttachment = ref(null);
|
||||
const templateParams = ref(null);
|
||||
const showConfirmClose = ref(false);
|
||||
const showWhatsAppTemplatesModal = ref(false);
|
||||
const datePickerOpen = ref(false);
|
||||
const contentError = ref(false);
|
||||
const contentLengthError = ref(false);
|
||||
const dateTimeError = ref('');
|
||||
@ -88,26 +87,6 @@ const originalHasAttachment = ref(false);
|
||||
// NOTE: Local ref to control modal visibility, prevents auto-close when unsaved changes exist
|
||||
const localShowModal = ref(false);
|
||||
|
||||
const datePickerLang = {
|
||||
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
months: [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
],
|
||||
yearFormat: 'YYYY',
|
||||
monthFormat: 'MMMM',
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
messageContent.value = '';
|
||||
scheduledDateTime.value = null;
|
||||
@ -275,21 +254,6 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const disablePastDates = date => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return date < today;
|
||||
};
|
||||
|
||||
const onDateTimeChange = value => {
|
||||
scheduledDateTime.value = value;
|
||||
dateTimeError.value = '';
|
||||
};
|
||||
|
||||
const closeDatePicker = () => {
|
||||
datePickerOpen.value = false;
|
||||
};
|
||||
|
||||
const onAttachmentsChange = value => {
|
||||
attachments.value = value.slice(0, 1);
|
||||
};
|
||||
@ -315,7 +279,7 @@ const isFutureSchedule = date => {
|
||||
const validatePayload = status => {
|
||||
contentError.value = false;
|
||||
contentLengthError.value = false;
|
||||
dateTimeError.value = null;
|
||||
dateTimeError.value = '';
|
||||
|
||||
const hasPayloadContent =
|
||||
hasContent.value ||
|
||||
@ -478,7 +442,7 @@ watch(
|
||||
class="[&_.modal-container]:!w-[45rem] [&_.modal-container]:!max-w-[90%]"
|
||||
size="medium"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-6 px-6 py-6" @click="closeDatePicker">
|
||||
<div class="flex w-full flex-col gap-6 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-n-slate-12">
|
||||
{{
|
||||
isEditing
|
||||
@ -523,41 +487,7 @@ watch(
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('SCHEDULED_MESSAGES.MODAL.DATETIME_LABEL') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex-1 min-w-0 [&_.mx-datepicker]:w-full [&_.mx-input-wrapper]:w-full [&_.mx-input]:w-full [&_.mx-input]:!mb-0"
|
||||
:class="
|
||||
dateTimeError
|
||||
? '[&_.mx-input]:!border-n-ruby-9 [&_.mx-input]:!border-solid'
|
||||
: ''
|
||||
"
|
||||
@click.stop
|
||||
>
|
||||
<DatePicker
|
||||
:value="scheduledDateTime"
|
||||
:open="datePickerOpen"
|
||||
type="datetime"
|
||||
:placeholder="t('SCHEDULED_MESSAGES.MODAL.DATETIME_PLACEHOLDER')"
|
||||
:lang="datePickerLang"
|
||||
:format="t('SCHEDULED_MESSAGES.MODAL.DATETIME_FORMAT')"
|
||||
value-type="date"
|
||||
:disabled-date="disablePastDates"
|
||||
:show-second="false"
|
||||
editable
|
||||
clearable
|
||||
append-to-body
|
||||
popup-class="z-[10000]"
|
||||
@open="datePickerOpen = true"
|
||||
@close="datePickerOpen = false"
|
||||
@change="onDateTimeChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="showAttachmentUpload" class="flex items-center gap-2 h-10">
|
||||
<FileUpload
|
||||
:accept="ALLOWED_FILE_TYPES"
|
||||
@ -619,6 +549,17 @@ watch(
|
||||
@update:attachments="onAttachmentsChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('SCHEDULED_MESSAGES.MODAL.SCHEDULE_LABEL') }}
|
||||
</span>
|
||||
<ScheduleDateShortcuts
|
||||
v-model="scheduledDateTime"
|
||||
:date-time-error="dateTimeError"
|
||||
@update:model-value="dateTimeError = ''"
|
||||
/>
|
||||
<span v-if="dateTimeError" class="text-xs text-n-ruby-9">
|
||||
{{ dateTimeError }}
|
||||
</span>
|
||||
|
||||
@ -62,6 +62,7 @@
|
||||
"axios": "^1.13.2",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"chart.js": "~4.4.4",
|
||||
"chrono-node": "^2.9.0",
|
||||
"color2k": "^2.0.2",
|
||||
"company-email-validator": "^1.1.0",
|
||||
"core-js": "3.38.1",
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -106,6 +106,9 @@ importers:
|
||||
chart.js:
|
||||
specifier: ~4.4.4
|
||||
version: 4.4.4
|
||||
chrono-node:
|
||||
specifier: ^2.9.0
|
||||
version: 2.9.0
|
||||
color2k:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.3
|
||||
@ -1854,6 +1857,10 @@ packages:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
chrono-node@2.9.0:
|
||||
resolution: {integrity: sha512-glI4YY2Jy6JII5l3d5FN6rcrIbKSQqKPhWsIRYPK2IK8Mm4Q1ZZFdYIaDqglUNf7gNwG+kWIzTn0omzzE0VkvQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
cli-boxes@3.0.0:
|
||||
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
|
||||
engines: {node: '>=10'}
|
||||
@ -6573,6 +6580,8 @@ snapshots:
|
||||
readdirp: 4.1.2
|
||||
optional: true
|
||||
|
||||
chrono-node@2.9.0: {}
|
||||
|
||||
cli-boxes@3.0.0: {}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user