Compare commits

...
Author SHA1 Message Date
Sojan Jose fb76db0fa8 fix(i18n): repair locale regressions in pr 13832 2026-03-17 14:40:17 -07:00
Captain 1927710da4 New translations snooze.json (Korean)
[ci skip]
2026-03-17 08:10:14 -07:00
Captain 0983812c91 New translations inboxmgmt.json (Urdu (India))
[ci skip]
2026-03-17 08:08:47 -07:00
Captain 4208c8cbff New translations inboxmgmt.json (Serbo-Croatian)
[ci skip]
2026-03-17 08:07:59 -07:00
Captain 2fb9f29330 New translations inboxmgmt.json (Nepali)
[ci skip]
2026-03-17 08:07:50 -07:00
Captain 26b5b59a43 New translations inboxmgmt.json (Amharic)
[ci skip]
2026-03-17 08:07:03 -07:00
Captain 63422de8b2 New translations inboxmgmt.json (Serbian (Latin))
[ci skip]
2026-03-17 08:06:07 -07:00
Captain 19097d8717 New translations agentmgmt.json (Serbian (Latin))
[ci skip]
2026-03-17 08:06:00 -07:00
Captain d2f7c6b9c7 New translations inboxmgmt.json (Malayalam)
[ci skip]
2026-03-17 08:05:15 -07:00
Captain e6e40307ef New translations inboxmgmt.json (Tagalog)
[ci skip]
2026-03-17 08:04:26 -07:00
Captain ba03c50d31 New translations inboxmgmt.json (Malay)
[ci skip]
2026-03-17 08:03:39 -07:00
Captain af54890f64 New translations inboxmgmt.json (Hindi)
[ci skip]
2026-03-17 08:02:46 -07:00
Captain 263099631e New translations inboxmgmt.json (Azerbaijani)
[ci skip]
2026-03-17 08:01:55 -07:00
Captain e2e33d62d2 New translations inboxmgmt.json (Latvian)
[ci skip]
2026-03-17 08:01:08 -07:00
Captain 95e7480a17 New translations inboxmgmt.json (Estonian)
[ci skip]
2026-03-17 08:00:15 -07:00
Captain 8c382f7def New translations automation.json (Thai)
[ci skip]
2026-03-17 07:59:49 -07:00
Captain 98240a1fe8 New translations inboxmgmt.json (Croatian)
[ci skip]
2026-03-17 07:59:32 -07:00
Captain dda3d26bbc New translations inboxmgmt.json (Thai)
[ci skip]
2026-03-17 07:58:53 -07:00
Captain 5cf02d97e7 New translations inboxmgmt.json (Bengali)
[ci skip]
2026-03-17 07:58:45 -07:00
Captain b112e92951 New translations inboxmgmt.json (Tamil)
[ci skip]
2026-03-17 07:58:07 -07:00
Captain 4d69469eb1 New translations inboxmgmt.json (Persian)
[ci skip]
2026-03-17 07:57:22 -07:00
Captain 5741ef7ef5 New translations inboxmgmt.json (Indonesian)
[ci skip]
2026-03-17 07:56:34 -07:00
Captain 8f1929b301 New translations inboxmgmt.json (Icelandic)
[ci skip]
2026-03-17 07:55:01 -07:00
Captain ebd243f1c6 New translations inboxmgmt.json (Vietnamese)
[ci skip]
2026-03-17 07:54:15 -07:00
Captain c1a4c92929 New translations yearinreview.json (Chinese Traditional)
[ci skip]
2026-03-17 07:54:07 -07:00
Captain 921fc73288 New translations companies.json (Chinese Traditional)
[ci skip]
2026-03-17 07:54:06 -07:00
Captain f2cf7aec66 New translations contenttemplates.json (Chinese Traditional)
[ci skip]
2026-03-17 07:54:03 -07:00
Captain 5ca949037b New translations components.json (Chinese Traditional)
[ci skip]
2026-03-17 07:54:02 -07:00
Captain e55ad929e4 New translations inbox.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:56 -07:00
Captain 25491f1396 New translations auditlogs.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:55 -07:00
Captain ea21858fca New translations search.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:53 -07:00
Captain 02ec4bd4e7 New translations emoji.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:51 -07:00
Captain 14376af585 New translations helpcenter.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:48 -07:00
Captain 51f40ac7a0 New translations whatsapptemplates.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:46 -07:00
Captain 9d9eccf9fb New translations bulkactions.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:44 -07:00
Captain 3e8378d114 New translations en.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:43 -07:00
Captain d826c93398 New translations contactfilters.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:41 -07:00
Captain b37ba79147 New translations advancedfilters.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:40 -07:00
Captain d1d2ff8934 New translations csatmgmt.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:36 -07:00
Captain 47ac012292 New translations campaign.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:34 -07:00
Captain c03748b574 New translations en.json (Chinese Traditional)
[ci skip]
2026-03-17 07:53:22 -07:00
Captain 1d0efee19e New translations en.yml (Chinese Traditional)
[ci skip]
2026-03-17 07:53:21 -07:00
Captain b8b0b35a2c New translations inboxmgmt.json (Urdu (Pakistan))
[ci skip]
2026-03-17 07:53:13 -07:00
Captain f612ce1c8f New translations yearinreview.json (Chinese Simplified)
[ci skip]
2026-03-17 07:53:06 -07:00
Captain ade8b18659 New translations companies.json (Chinese Simplified)
[ci skip]
2026-03-17 07:53:04 -07:00
Captain 956981748c New translations setnewpassword.json (Chinese Traditional)
[ci skip]
2026-03-17 07:52:34 -07:00
Captain 9d4a201c7f New translations resetpassword.json (Chinese Traditional)
[ci skip]
2026-03-17 07:52:26 -07:00
Captain dd67657eac New translations login.json (Chinese Traditional)
[ci skip]
2026-03-17 07:52:23 -07:00
Captain ac34d5a54e New translations inboxmgmt.json (Chinese Traditional)
[ci skip]
2026-03-17 07:52:21 -07:00
Captain 976e658e8a New translations en.json (Ukrainian)
[ci skip]
2026-03-17 07:51:46 -07:00
Captain cd25012eed New translations inboxmgmt.json (Chinese Simplified)
[ci skip]
2026-03-17 07:51:35 -07:00
Captain cc1289e9f9 New translations inboxmgmt.json (Ukrainian)
[ci skip]
2026-03-17 07:51:28 -07:00
Captain 93c222c09b New translations inboxmgmt.json (Swedish)
[ci skip]
2026-03-17 07:49:58 -07:00
Captain c9f1e0944f New translations inboxmgmt.json (Albanian)
[ci skip]
2026-03-17 07:49:10 -07:00
Captain 3bf54ab660 New translations inboxmgmt.json (Slovenian)
[ci skip]
2026-03-17 07:48:19 -07:00
Captain 0bd8a0df58 New translations auditlogs.json (Russian)
[ci skip]
2026-03-17 07:48:00 -07:00
Captain bdd87a3912 New translations agentbots.json (Russian)
[ci skip]
2026-03-17 07:47:56 -07:00
Captain 867399ec91 New translations bulkactions.json (Russian)
[ci skip]
2026-03-17 07:47:52 -07:00
Captain 93f35beb6b New translations automation.json (Russian)
[ci skip]
2026-03-17 07:47:46 -07:00
Captain e921da31c7 New translations attributesmgmt.json (Russian)
[ci skip]
2026-03-17 07:47:44 -07:00
Captain 84ac34c154 New translations inboxmgmt.json (Slovak)
[ci skip]
2026-03-17 07:47:24 -07:00
Captain 93412bf249 New translations contact.json (Slovak)
[ci skip]
2026-03-17 07:47:20 -07:00
Captain ff78bb528e New translations chatlist.json (Slovak)
[ci skip]
2026-03-17 07:47:18 -07:00
Captain b2c73b92f1 New translations inboxmgmt.json (Russian)
[ci skip]
2026-03-17 07:46:26 -07:00
Captain 7e5c041715 New translations inboxmgmt.json (Portuguese)
[ci skip]
2026-03-17 07:45:35 -07:00
Captain a84458c5ed New translations inboxmgmt.json (Polish)
[ci skip]
2026-03-17 07:44:43 -07:00
Captain 238b11ce78 New translations inboxmgmt.json (Norwegian)
[ci skip]
2026-03-17 07:44:33 -07:00
Captain 0483a0611d New translations inboxmgmt.json (Dutch)
[ci skip]
2026-03-17 07:43:40 -07:00
Captain 1b99d74360 New translations yearinreview.json (Korean)
[ci skip]
2026-03-17 07:43:22 -07:00
Captain afaf640638 New translations companies.json (Korean)
[ci skip]
2026-03-17 07:43:20 -07:00
Captain c811d33598 New translations mfa.json (Korean)
[ci skip]
2026-03-17 07:43:19 -07:00
Captain e14653d209 New translations inboxmgmt.json (Lithuanian)
[ci skip]
2026-03-17 07:42:37 -07:00
Captain e9d86c23c8 New translations contenttemplates.json (Korean)
[ci skip]
2026-03-17 07:42:28 -07:00
Captain 918ed3c22d New translations components.json (Korean)
[ci skip]
2026-03-17 07:42:22 -07:00
Captain deee596bdd New translations customrole.json (Korean)
[ci skip]
2026-03-17 07:42:20 -07:00
Captain f6675d9168 New translations datepicker.json (Korean)
[ci skip]
2026-03-17 07:42:19 -07:00
Captain e294348c4a New translations general.json (Korean)
[ci skip]
2026-03-17 07:42:17 -07:00
Captain 67a9e2e9c3 New translations sla.json (Korean)
[ci skip]
2026-03-17 07:42:15 -07:00
Captain fcd8fd34cc New translations inbox.json (Korean)
[ci skip]
2026-03-17 07:42:13 -07:00
Captain d96267ca31 New translations auditlogs.json (Korean)
[ci skip]
2026-03-17 07:42:12 -07:00
Captain e36239c66b New translations search.json (Korean)
[ci skip]
2026-03-17 07:42:10 -07:00
Captain a8dc30291e New translations emoji.json (Korean)
[ci skip]
2026-03-17 07:42:05 -07:00
Captain 23ffda5cb9 New translations macros.json (Korean)
[ci skip]
2026-03-17 07:42:04 -07:00
Captain 4d02a6c69f New translations agentbots.json (Korean)
[ci skip]
2026-03-17 07:42:02 -07:00
Captain c1b2a4fe72 New translations helpcenter.json (Korean)
[ci skip]
2026-03-17 07:42:00 -07:00
Captain 5882d7bab2 New translations whatsapptemplates.json (Korean)
[ci skip]
2026-03-17 07:41:58 -07:00
Captain 6f303fd0fa New translations bulkactions.json (Korean)
[ci skip]
2026-03-17 07:41:56 -07:00
Captain 093fd2895f New translations contactfilters.json (Korean)
[ci skip]
2026-03-17 07:41:53 -07:00
Captain c70645e46b New translations advancedfilters.json (Korean)
[ci skip]
2026-03-17 07:41:52 -07:00
Captain 0a8e116948 New translations automation.json (Korean)
[ci skip]
2026-03-17 07:41:50 -07:00
Captain ba024c557a New translations attributesmgmt.json (Korean)
[ci skip]
2026-03-17 07:41:48 -07:00
Captain 196ed4b0e1 New translations csatmgmt.json (Korean)
[ci skip]
2026-03-17 07:41:47 -07:00
Captain 086491323d New translations integrationapps.json (Korean)
[ci skip]
2026-03-17 07:41:45 -07:00
Captain 2a69cfac83 New translations campaign.json (Korean)
[ci skip]
2026-03-17 07:41:43 -07:00
Captain 9d7a0199e3 New translations teamssettings.json (Korean)
[ci skip]
2026-03-17 07:41:41 -07:00
Captain bc84b69cbe New translations labelsmgmt.json (Korean)
[ci skip]
2026-03-17 07:41:40 -07:00
Captain 266b520ac4 New translations en.yml (Korean)
[ci skip]
2026-03-17 07:41:37 -07:00
Captain d496338236 New translations devise.en.yml (Korean)
[ci skip]
2026-03-17 07:41:35 -07:00
Captain e5503e128e New translations signup.json (Korean)
[ci skip]
2026-03-17 07:41:32 -07:00
Captain 7287c8d133 New translations settings.json (Korean)
[ci skip]
2026-03-17 07:41:31 -07:00
Captain b498643181 New translations setnewpassword.json (Korean)
[ci skip]
2026-03-17 07:41:29 -07:00
Captain 55e35fe9e9 New translations resetpassword.json (Korean)
[ci skip]
2026-03-17 07:41:27 -07:00
Captain c2ab5e61b0 New translations report.json (Korean)
[ci skip]
2026-03-17 07:41:25 -07:00
Captain c7c8c0814e New translations login.json (Korean)
[ci skip]
2026-03-17 07:41:23 -07:00
Captain bf95080f3b New translations integrations.json (Korean)
[ci skip]
2026-03-17 07:41:21 -07:00
Captain fcadbe62a9 New translations inboxmgmt.json (Korean)
[ci skip]
2026-03-17 07:41:20 -07:00
Captain 299be3b53d New translations generalsettings.json (Korean)
[ci skip]
2026-03-17 07:41:18 -07:00
Captain 4fbcc1ffac New translations conversation.json (Korean)
[ci skip]
2026-03-17 07:41:16 -07:00
Captain 8b11f4208e New translations contact.json (Korean)
[ci skip]
2026-03-17 07:41:14 -07:00
Captain 857ee2d771 New translations chatlist.json (Korean)
[ci skip]
2026-03-17 07:41:12 -07:00
Captain a6ae79cce6 New translations cannedmgmt.json (Korean)
[ci skip]
2026-03-17 07:41:10 -07:00
Captain b3ac25452a New translations agentmgmt.json (Korean)
[ci skip]
2026-03-17 07:41:08 -07:00
Captain f9b4e7789b New translations inboxmgmt.json (Georgian)
[ci skip]
2026-03-17 07:40:13 -07:00
Captain 3891b113a5 New translations inboxmgmt.json (Japanese)
[ci skip]
2026-03-17 07:39:16 -07:00
Captain 26decef2bb New translations inboxmgmt.json (Italian)
[ci skip]
2026-03-17 07:38:20 -07:00
Captain 828a86f450 New translations inboxmgmt.json (Armenian)
[ci skip]
2026-03-17 07:37:32 -07:00
Captain 90594d0490 New translations inboxmgmt.json (Hungarian)
[ci skip]
2026-03-17 07:36:45 -07:00
Captain 77d8e87477 New translations inboxmgmt.json (Hebrew)
[ci skip]
2026-03-17 07:35:57 -07:00
Captain 8d51910a25 New translations inboxmgmt.json (Finnish)
[ci skip]
2026-03-17 07:35:10 -07:00
Captain 6e4f584186 New translations inboxmgmt.json (Greek)
[ci skip]
2026-03-17 07:34:21 -07:00
Captain d2f5b27083 New translations inboxmgmt.json (German)
[ci skip]
2026-03-17 07:34:12 -07:00
Captain cf61b42806 New translations inboxmgmt.json (Danish)
[ci skip]
2026-03-17 07:33:21 -07:00
Captain 912a413497 New translations inboxmgmt.json (Czech)
[ci skip]
2026-03-17 07:32:23 -07:00
Captain bad3429456 New translations inboxmgmt.json (Catalan)
[ci skip]
2026-03-17 07:31:32 -07:00
Captain e3a25221d3 New translations en.json (Bulgarian)
[ci skip]
2026-03-17 07:30:51 -07:00
Captain ce9b53ea21 New translations inboxmgmt.json (Bulgarian)
[ci skip]
2026-03-17 07:30:38 -07:00
Captain 5ca8668065 New translations inboxmgmt.json (Arabic)
[ci skip]
2026-03-17 07:29:52 -07:00
Captain 067d3c9511 New translations agentmgmt.json (Arabic)
[ci skip]
2026-03-17 07:29:46 -07:00
Captain 97e3577c3f New translations integrations.json (Spanish)
[ci skip]
2026-03-17 07:29:06 -07:00
Captain 32c8a7375d New translations inboxmgmt.json (Spanish)
[ci skip]
2026-03-17 07:29:04 -07:00
Captain 16de28e209 New translations inboxmgmt.json (French)
[ci skip]
2026-03-17 07:28:23 -07:00
Captain 23ff945207 New translations inboxmgmt.json (Romanian)
[ci skip]
2026-03-17 07:27:48 -07:00
Captain 8534126a57 New translations inboxmgmt.json (Portuguese, Brazilian)
[ci skip]
2026-03-17 07:27:44 -07:00
Captain d0cd25b9d5 New translations inboxmgmt.json (Turkish)
[ci skip]
2026-03-17 07:27:38 -07:00
Captain 4a60833c5f New translations chatlist.json (Chinese Simplified)
[ci skip]
2026-03-17 07:27:24 -07:00
Captain 2a9353f91d New translations snooze.json (Korean)
[ci skip]
2026-03-16 21:03:53 -07:00
Captain 5d4f830e1c New translations inboxmgmt.json (Urdu (India))
[ci skip]
2026-03-16 21:03:10 -07:00
Captain 9513a80cb6 New translations inboxmgmt.json (Serbo-Croatian)
[ci skip]
2026-03-16 21:02:34 -07:00
Captain e1790280ae New translations inboxmgmt.json (Nepali)
[ci skip]
2026-03-16 21:01:58 -07:00
Captain 6c53b826d9 New translations inboxmgmt.json (Amharic)
[ci skip]
2026-03-16 21:01:22 -07:00
Captain 72bfb2f197 New translations inboxmgmt.json (Serbian (Latin))
[ci skip]
2026-03-16 21:00:44 -07:00
Captain f6e366a776 New translations inboxmgmt.json (Malayalam)
[ci skip]
2026-03-16 21:00:05 -07:00
Captain 2661702a6e New translations inboxmgmt.json (Tagalog)
[ci skip]
2026-03-16 20:59:32 -07:00
Captain a0d36a0d75 New translations inboxmgmt.json (Malay)
[ci skip]
2026-03-16 20:59:00 -07:00
Captain cf6b41fa3c New translations inboxmgmt.json (Hindi)
[ci skip]
2026-03-16 20:58:27 -07:00
Captain bc14b68c37 New translations inboxmgmt.json (Azerbaijani)
[ci skip]
2026-03-16 20:57:54 -07:00
Captain 7f260e0cf8 New translations inboxmgmt.json (Latvian)
[ci skip]
2026-03-16 20:57:20 -07:00
Captain 00d8776c1d New translations inboxmgmt.json (Estonian)
[ci skip]
2026-03-16 20:56:46 -07:00
Captain 2dfa127569 New translations inboxmgmt.json (Croatian)
[ci skip]
2026-03-16 20:56:13 -07:00
Captain e8ac561e04 New translations inboxmgmt.json (Thai)
[ci skip]
2026-03-16 20:55:40 -07:00
Captain 8b2c71e096 New translations inboxmgmt.json (Bengali)
[ci skip]
2026-03-16 20:55:07 -07:00
Captain d52a616310 New translations inboxmgmt.json (Tamil)
[ci skip]
2026-03-16 20:54:34 -07:00
Captain 880eea8adb New translations inboxmgmt.json (Persian)
[ci skip]
2026-03-16 20:54:01 -07:00
Captain 126639f4bd New translations inboxmgmt.json (Indonesian)
[ci skip]
2026-03-16 20:53:27 -07:00
Captain 6e2c992296 New translations inboxmgmt.json (Icelandic)
[ci skip]
2026-03-16 20:52:27 -07:00
Captain 15cc5fe6ca New translations inboxmgmt.json (Vietnamese)
[ci skip]
2026-03-16 20:51:54 -07:00
Captain e598c88853 New translations inboxmgmt.json (Urdu (Pakistan))
[ci skip]
2026-03-16 20:51:20 -07:00
Captain f668e0ee30 New translations yearinreview.json (Chinese Traditional)
[ci skip]
2026-03-16 20:51:14 -07:00
Captain 65a238f8d4 New translations companies.json (Chinese Traditional)
[ci skip]
2026-03-16 20:51:13 -07:00
Captain b7b683afd2 New translations contenttemplates.json (Chinese Traditional)
[ci skip]
2026-03-16 20:51:11 -07:00
Captain 0427bd2686 New translations components.json (Chinese Traditional)
[ci skip]
2026-03-16 20:51:10 -07:00
Captain 075d2af7f1 New translations inbox.json (Chinese Traditional)
[ci skip]
2026-03-16 20:51:05 -07:00
Captain 46b00e4180 New translations auditlogs.json (Chinese Traditional)
[ci skip]
2026-03-16 20:51:04 -07:00
Captain c24b2c3d3d New translations search.json (Chinese Traditional)
[ci skip]
2026-03-16 20:51:03 -07:00
Captain 46da5042df New translations emoji.json (Chinese Traditional)
[ci skip]
2026-03-16 20:51:02 -07:00
Captain eb09371a52 New translations helpcenter.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:59 -07:00
Captain f07b58b110 New translations whatsapptemplates.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:58 -07:00
Captain 3f1641af9c New translations bulkactions.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:56 -07:00
Captain e2bcef19bd New translations en.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:55 -07:00
Captain f412faf874 New translations contactfilters.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:54 -07:00
Captain 434c6ae127 New translations advancedfilters.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:53 -07:00
Captain 853f197d07 New translations csatmgmt.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:50 -07:00
Captain 1e5f88c3ab New translations campaign.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:48 -07:00
Captain 083a223b18 New translations en.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:45 -07:00
Captain c9a21a9792 New translations en.yml (Chinese Traditional)
[ci skip]
2026-03-16 20:50:44 -07:00
Captain 4d6446fe23 New translations devise.en.yml (Chinese Traditional)
[ci skip]
2026-03-16 20:50:43 -07:00
Captain 0db3795cae New translations setnewpassword.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:39 -07:00
Captain c9932f982f New translations resetpassword.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:38 -07:00
Captain 5f922ad6bc New translations login.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:36 -07:00
Captain 6b5e8de429 New translations inboxmgmt.json (Chinese Traditional)
[ci skip]
2026-03-16 20:50:34 -07:00
Captain 8ac8f4433e New translations inboxmgmt.json (Chinese Simplified)
[ci skip]
2026-03-16 20:49:59 -07:00
Captain 4e54df7cfb New translations inboxmgmt.json (Ukrainian)
[ci skip]
2026-03-16 20:49:27 -07:00
Captain 71230c6cec New translations inboxmgmt.json (Swedish)
[ci skip]
2026-03-16 20:48:29 -07:00
Captain a94d1cde34 New translations inboxmgmt.json (Albanian)
[ci skip]
2026-03-16 20:47:55 -07:00
Captain 38d9e35b5c New translations inboxmgmt.json (Slovenian)
[ci skip]
2026-03-16 20:47:21 -07:00
Captain 5076a05d4c New translations inboxmgmt.json (Slovak)
[ci skip]
2026-03-16 20:46:47 -07:00
Captain ee6300fb25 New translations auditlogs.json (Russian)
[ci skip]
2026-03-16 20:46:34 -07:00
Captain 21dadb910a New translations agentbots.json (Russian)
[ci skip]
2026-03-16 20:46:30 -07:00
Captain 4f9246391b New translations bulkactions.json (Russian)
[ci skip]
2026-03-16 20:46:28 -07:00
Captain 85bdff5386 New translations automation.json (Russian)
[ci skip]
2026-03-16 20:46:24 -07:00
Captain aea132ff4f New translations attributesmgmt.json (Russian)
[ci skip]
2026-03-16 20:46:23 -07:00
Captain 14f6444d58 New translations inboxmgmt.json (Russian)
[ci skip]
2026-03-16 20:46:09 -07:00
Captain 5edb3ee485 New translations inboxmgmt.json (Portuguese)
[ci skip]
2026-03-16 20:45:35 -07:00
Captain bf478d6231 New translations inboxmgmt.json (Polish)
[ci skip]
2026-03-16 20:45:01 -07:00
Captain 8d657ea2f2 New translations inboxmgmt.json (Norwegian)
[ci skip]
2026-03-16 20:44:27 -07:00
Captain 9566f2bd11 New translations inboxmgmt.json (Dutch)
[ci skip]
2026-03-16 20:43:53 -07:00
Captain 7c58bb8eef New translations inboxmgmt.json (Lithuanian)
[ci skip]
2026-03-16 20:43:20 -07:00
Captain e4605d7bdf New translations yearinreview.json (Korean)
[ci skip]
2026-03-16 20:43:14 -07:00
Captain af2bea6e0b New translations companies.json (Korean)
[ci skip]
2026-03-16 20:43:13 -07:00
Captain 2d84608029 New translations mfa.json (Korean)
[ci skip]
2026-03-16 20:43:12 -07:00
Captain c8bffe5c13 New translations contenttemplates.json (Korean)
[ci skip]
2026-03-16 20:43:10 -07:00
Captain fc46ce427e New translations components.json (Korean)
[ci skip]
2026-03-16 20:43:09 -07:00
Captain 856b254719 New translations customrole.json (Korean)
[ci skip]
2026-03-16 20:43:08 -07:00
Captain dcf389e32f New translations datepicker.json (Korean)
[ci skip]
2026-03-16 20:43:06 -07:00
Captain 6e4a5f3ef1 New translations general.json (Korean)
[ci skip]
2026-03-16 20:43:05 -07:00
Captain a0a745bd50 New translations sla.json (Korean)
[ci skip]
2026-03-16 20:43:04 -07:00
Captain 53d7908a14 New translations inbox.json (Korean)
[ci skip]
2026-03-16 20:43:03 -07:00
Captain 997d5fe878 New translations auditlogs.json (Korean)
[ci skip]
2026-03-16 20:43:01 -07:00
Captain 124d16a7cb New translations search.json (Korean)
[ci skip]
2026-03-16 20:43:00 -07:00
Captain 515ddc34f0 New translations emoji.json (Korean)
[ci skip]
2026-03-16 20:42:59 -07:00
Captain 6ab51a6530 New translations macros.json (Korean)
[ci skip]
2026-03-16 20:42:58 -07:00
Captain 9f0c9daf85 New translations agentbots.json (Korean)
[ci skip]
2026-03-16 20:42:56 -07:00
Captain 44ae8ba6d0 New translations helpcenter.json (Korean)
[ci skip]
2026-03-16 20:42:55 -07:00
Captain a74d71c1c3 New translations whatsapptemplates.json (Korean)
[ci skip]
2026-03-16 20:42:54 -07:00
Captain 947287af7f New translations bulkactions.json (Korean)
[ci skip]
2026-03-16 20:42:52 -07:00
Captain d00081b871 New translations contactfilters.json (Korean)
[ci skip]
2026-03-16 20:42:51 -07:00
Captain 47a50ae3e2 New translations advancedfilters.json (Korean)
[ci skip]
2026-03-16 20:42:49 -07:00
Captain 0b70e8ae53 New translations automation.json (Korean)
[ci skip]
2026-03-16 20:42:48 -07:00
Captain 6be138614d New translations attributesmgmt.json (Korean)
[ci skip]
2026-03-16 20:42:46 -07:00
Captain 5aa2639b49 New translations csatmgmt.json (Korean)
[ci skip]
2026-03-16 20:42:45 -07:00
Captain 7668ccdc46 New translations integrationapps.json (Korean)
[ci skip]
2026-03-16 20:42:44 -07:00
Captain c85cde5550 New translations campaign.json (Korean)
[ci skip]
2026-03-16 20:42:42 -07:00
Captain 572af818e5 New translations teamssettings.json (Korean)
[ci skip]
2026-03-16 20:42:41 -07:00
Captain 29b5eed897 New translations labelsmgmt.json (Korean)
[ci skip]
2026-03-16 20:42:40 -07:00
Captain 95cfcfc0ad New translations en.yml (Korean)
[ci skip]
2026-03-16 20:42:38 -07:00
Captain 8988126dc1 New translations devise.en.yml (Korean)
[ci skip]
2026-03-16 20:42:37 -07:00
Captain 68626a8e65 New translations signup.json (Korean)
[ci skip]
2026-03-16 20:42:34 -07:00
Captain 3537b61a02 New translations settings.json (Korean)
[ci skip]
2026-03-16 20:42:33 -07:00
Captain abf0e93548 New translations setnewpassword.json (Korean)
[ci skip]
2026-03-16 20:42:31 -07:00
Captain 8e762f0481 New translations resetpassword.json (Korean)
[ci skip]
2026-03-16 20:42:30 -07:00
Captain c7399339d7 New translations report.json (Korean)
[ci skip]
2026-03-16 20:42:28 -07:00
Captain 141b4b0fa9 New translations login.json (Korean)
[ci skip]
2026-03-16 20:42:27 -07:00
Captain 79b8fd31ba New translations integrations.json (Korean)
[ci skip]
2026-03-16 20:42:26 -07:00
Captain 5dcd8b1bb2 New translations inboxmgmt.json (Korean)
[ci skip]
2026-03-16 20:42:24 -07:00
Captain a07f6dddec New translations generalsettings.json (Korean)
[ci skip]
2026-03-16 20:42:23 -07:00
Captain 9acecbb03d New translations conversation.json (Korean)
[ci skip]
2026-03-16 20:42:22 -07:00
Captain a107408d57 New translations contact.json (Korean)
[ci skip]
2026-03-16 20:42:20 -07:00
Captain 9e6b0452b5 New translations chatlist.json (Korean)
[ci skip]
2026-03-16 20:42:19 -07:00
Captain 5703096d4f New translations cannedmgmt.json (Korean)
[ci skip]
2026-03-16 20:42:18 -07:00
Captain 2753b009d9 New translations agentmgmt.json (Korean)
[ci skip]
2026-03-16 20:42:16 -07:00
Captain 0ca5fffaa4 New translations inboxmgmt.json (Georgian)
[ci skip]
2026-03-16 20:41:46 -07:00
Captain 95adfc155a New translations inboxmgmt.json (Japanese)
[ci skip]
2026-03-16 20:41:12 -07:00
Captain 2e6e01c5b3 New translations inboxmgmt.json (Italian)
[ci skip]
2026-03-16 20:40:38 -07:00
Captain eb3bb7f53c New translations inboxmgmt.json (Armenian)
[ci skip]
2026-03-16 20:40:05 -07:00
Captain 4923a58fa0 New translations inboxmgmt.json (Hungarian)
[ci skip]
2026-03-16 20:39:31 -07:00
Captain ba26d63692 New translations inboxmgmt.json (Hebrew)
[ci skip]
2026-03-16 20:38:56 -07:00
Captain 91b738ccb7 New translations inboxmgmt.json (Finnish)
[ci skip]
2026-03-16 20:38:22 -07:00
Captain 210b36cd4e New translations inboxmgmt.json (Greek)
[ci skip]
2026-03-16 20:37:46 -07:00
Captain 5bb81736c7 New translations inboxmgmt.json (German)
[ci skip]
2026-03-16 20:37:12 -07:00
Captain ad46e98c4d New translations inboxmgmt.json (Danish)
[ci skip]
2026-03-16 20:36:37 -07:00
Captain d20ed19f7e New translations inboxmgmt.json (Czech)
[ci skip]
2026-03-16 20:36:02 -07:00
Captain 3769ad404d New translations inboxmgmt.json (Catalan)
[ci skip]
2026-03-16 20:35:25 -07:00
Captain 89372561cd New translations en.json (Bulgarian)
[ci skip]
2026-03-16 20:34:58 -07:00
Captain f8a46ccecd New translations inboxmgmt.json (Bulgarian)
[ci skip]
2026-03-16 20:34:49 -07:00
Captain fd91c54286 New translations inboxmgmt.json (Arabic)
[ci skip]
2026-03-16 20:34:15 -07:00
Captain 753a01e21c New translations inboxmgmt.json (Spanish)
[ci skip]
2026-03-16 20:33:43 -07:00
Captain 64f1523ee3 New translations inboxmgmt.json (French)
[ci skip]
2026-03-16 20:33:08 -07:00
Captain 513714ec14 New translations inboxmgmt.json (Portuguese, Brazilian)
[ci skip]
2026-03-16 20:32:34 -07:00
Captain 6c527ee54b New translations inboxmgmt.json (Turkish)
[ci skip]
2026-03-16 20:32:23 -07:00
Captain f9ec61ee87 New translations inboxmgmt.json (Romanian)
[ci skip]
2026-03-16 20:32:10 -07:00
a62beffeef fix(i18n): complete zh_TW locale coverage (#13792)
This updates the Traditional Chinese (`zh_TW`) locale coverage across
Chatwoot so the app no longer falls back to English for missing backend,
dashboard, widget, and survey strings.

## How to test

1. Start Chatwoot locally and switch the UI locale to Traditional
Chinese (`zh_TW`).
2. Walk through the main product areas: dashboard, settings, inbox
management, help center, automations, reports, widget, and survey flows.
3. Confirm the UI surfaces translated Traditional Chinese copy instead
of English fallbacks.
4. Spot-check newly added locale surfaces such as secure password
messaging and snooze UI copy.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-16 18:47:22 -07:00
Denis PetrovandGitHub b8f6fe5bb7 feat: Bulgarian locale updates(#13635)
Bulgarian locale updates
2026-03-16 17:47:25 -07:00
b866886b55 feat(i18n): complete Korean (ko) translations to 100% coverage (#13583)
Translate all English strings to Korean across 41 frontend locale files
and 2 backend locale files. Add structurally missing keys and translate
existing keys that were left in English.

# Pull Request Template

## Description

Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-16 16:54:26 -07:00
b88236e86e chore(i18n): update Russian translations (#13405)
# Pull Request Template

## Description

Updated few i18n files to:
1. fix typos / grammar / punctuation
2. translate strings that were still in english
3. add missing keys

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

i18n change, the format remained the same.

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-16 16:10:16 -07:00
11ee741716 chore: Update translations (#13227)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-16 15:44:32 -07:00
Aakash BakhleandGitHub ac93290c9a fix: skip captain auto-open for templates (#13802)
# Pull Request Template

## Description

WhatsApp template messages are treated as outgoing human messages so
conversations are automatically marked Open, preventing Captain from
responding. #13673

This PR fixes the behaviour for template messages, so that captain can
respond to them.

## Type of change


- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-03-16 18:54:12 +05:30
03719cede0 fix: Correct reversed message status indicators for API channel (#13594)
## Description

Fixes the reversed message delivery status indicators for the API
channel. The API inbox was grouped with the web widget inbox in the
`isDelivered` computed property, causing both to treat a `sent` status
as `delivered`. Since the API channel provides real
`sent`/`delivered`/`read` status values from external systems (unlike
the web widget which has no separate delivery confirmation), the API
inbox needs its own handling.

**Before this fix:**
- Status `sent` (0) → incorrectly showed delivered checkmarks
- Status `delivered` (1) → incorrectly showed "Sending" spinner

**After this fix:**
- Status `sent` → correctly shows sent indicator (single checkmark)
- Status `delivered` → correctly shows delivered indicator (double
checkmarks)
- Status `read` → unchanged (already worked correctly)

The web widget inbox behavior is unchanged — it still treats `sent` as
`delivered` since it lacks a separate delivery confirmation mechanism.

Fixes #13576

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Verified by code review that the computed properties now correctly map
API channel message statuses:
- `isSent` returns `true` when `status === 'sent'` for API inbox
- `isDelivered` returns `true` when `status === 'delivered'` for API
inbox
- `isRead` unchanged — already checks `status === 'read'` for API inbox
- Web widget inbox logic is unchanged

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] My changes generate no new warnings

*This PR was created with the assistance of Claude Opus 4.6 by
Anthropic. Happy to make any adjustments! Reviewed and submitted by a
human.*

Co-authored-by: Your Name <your-email@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-03-16 13:21:18 +04:00
a5c50354fc feat: trigger assignment on resolve (#13780)
## Description

When an agent resolves or snoozes a conversation, their capacity frees
up — but under Assignment V2, no new conversation was assigned until the
next event triggered the assignment job. This meant agents could sit
idle despite having queued conversations waiting. This change triggers
the AssignmentJob immediately on resolve/snooze so freed capacity is
utilized right away.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- Enable auto assignment v2 on an inbox
- Create a conversation (gets auto-assigned to an available agent)
- Resolve the conversation
- Verify that another unassigned conversation in the inbox gets assigned
to the freed-up agent

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: tds-1 <tds-1@users.noreply.github.com>
2026-03-16 13:13:37 +05:30
a452ce9e84 feat(whatsapp): add webhook registration and status endpoints (#13551)
## Description

Adds webhook configuration management for WhatsApp Cloud API channels,
allowing administrators to check webhook status and register webhooks
directly from Chatwoot without accessing Meta Business Manager.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)


## Screenshots

<img width="1130" height="676" alt="Screenshot 2026-03-05 at 7 04 18 PM"
src="https://github.com/user-attachments/assets/f5dcd9dd-8827-42c5-a52b-1024012703c2"
/>
<img width="1101" height="651" alt="Screenshot 2026-03-05 at 7 04 29 PM"
src="https://github.com/user-attachments/assets/e0bd59f9-2a90-4f24-87c0-b79f21e721ee"
/>



## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-03-16 12:48:16 +05:30
Tanmay Deep SharmaandGitHub 28bf9fa5f9 fix: upgrade markdown-it to 14.1.1 to remediate CVE-2026-2327 (#13782)
## Linear tickets
-
https://linear.app/chatwoot/issue/CW-6607/vanta-remediate-medium-vulnerabilities-identified-in-packages-are
-
https://linear.app/chatwoot/issue/CW-6612/vanta-remediate-medium-vulnerabilities-identified-in-packages-are

## Description

Upgrades markdown-it from 13.0.2 to 14.1.1 to remediate CVE-2026-2327

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- Sanity testing of golden flows via UI

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-16 11:11:19 +05:30
Shivam MishraandGitHub 73a90f2841 feat: update bunny video support in HC (#13815)
Bunny Video has added a new URL player.mediadelivery.net, this PR adds
support for the new URL
2026-03-16 11:04:27 +05:30
Aakash BakhleandGitHub a90ffe6264 feat: Add force legacy auto-resolve flag (#13804)
# Pull Request Template

## Description

Add account setting and store_accessor for
`captain_force_legacy_auto_resolve`.
Enterprise job now skips LLM evaluation when this flag is true and falls
back to legacy time-based resolution. Add spec to cover the fallback.


## Type of change

We recently rolled out Captain deciding if a conversation is resolved or
not. While it is an improvement for majority of customers, some still
prefer the old way of auto-resolving based on inactivity. This PR adds a
check.

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

legacy_auto_resolve = true

<img width="1282" height="848" alt="CleanShot 2026-03-13 at 19 55 55@2x"
src="https://github.com/user-attachments/assets/dfdcc5d5-6d21-462b-87a6-a5e1b1290a8b"
/>


legacy_auto_resolve = false
<img width="1268" height="864" alt="CleanShot 2026-03-13 at 20 00 50@2x"
src="https://github.com/user-attachments/assets/f4719ec6-922a-4c3b-bc45-7b29eaced565"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-03-13 15:04:58 -07:00
Alexander UdovichenkoandGitHub 412b72db7c fix: Delete double hmac check (#12464)
## Description

When hmac identity check is enabled according to
[this](https://www.chatwoot.com/hc/user-guide/articles/1677587479-how-to-enable-identity-validation-in-chatwoot)
I found out, that it checked twice.

If `should_verify_hmac? -> true` then hmac checked in `before_action`
and we don't need to do it again later.

This perfomance related and PR fixes this.
2026-03-13 02:30:17 -07:00
Aakash BakhleandGitHub 8aa49f69d2 fix: prefer system API key for completion service (#13799)
# Pull Request Template

## Description

Add an api_key override so internal conversation completions prefers
using the system API key and do not consume customer OpenAI credits.
## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

specs and locally

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-03-13 13:10:10 +05:30
Shivam MishraandGitHub 550b408656 fix: restrict existing user sign-in to account members (#13793)
SAML sign-in now only links an existing user when that user already
belongs to the account that initiated SSO. New users can still be
created for SAML-enabled accounts, and invited members can continue to
sign in through their IdP, but SAML will no longer auto-attach an
unrelated existing user record during login.

**What changed**
- Added an account-membership check before SAML reuses an existing user
by email.
- Kept first-time SAML user creation unchanged for valid new users.
- Added builder and request specs covering the allowed and rejected
login paths.
2026-03-13 12:22:25 +05:30
b103747584 fix: skip Enter key submission during IME composition in AI inputs (#13779)
# Pull Request Template

## Description

CJK language users (Chinese, Japanese, Korean, etc.) use IME where Enter
confirms character selection. AI input components were intercepting
Enter unconditionally, making them unusable for IME users.

Add `event.isComposing` check to CopilotEditor, CopilotInput, and
AssistantPlayground so Enter during active IME composition is ignored.


## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

Before:

Add a Japenese keyboard, then go to AI follow-ups, type some word,
selecting it with enter submits the follow up. So CJK users cannot use
follow-ups.



https://github.com/user-attachments/assets/53517432-d97b-47fc-a802-81675e31d5c9



After:

Type a word, press enter to choose it, press enter again to unselect it
and enter again to send


https://github.com/user-attachments/assets/6c2a420b-7ee6-4c71-82a6-d9f1d7bbf31a



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:26:51 +05:30
d6d38cdd7d feat: captain decides if conversation should be resolved or kept open (#13336)
# Pull Request Template

## Description

captain decides if conversation should be resolved or open

Fixes
https://linear.app/chatwoot/issue/AI-91/make-captain-resolution-time-configurable

Update: Added 2 entries in reporting events:
`conversation_captain_handoff` and `conversation_captain_resolved`

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

LLM call decides that conversation is resolved, drops a private note
<img width="1228" height="438" alt="image"
src="https://github.com/user-attachments/assets/fb2cf1e9-4b2b-458b-a1e2-45c53d6a0158"
/>

LLM call decides conversation is still open as query was not resolved
<img width="1215" height="573" alt="image"
src="https://github.com/user-attachments/assets/2d1d5322-f567-487e-954e-11ab0798d11c"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-13 10:03:58 +05:30
Sojan JoseandGitHub 199dcd382e fix: Skip redundant contact saves in ContactIdentifyAction (#13778)
When the SDK sends identify calls with identical payloads (common on
every page load), `save!` fires even though no attributes changed. While
Rails skips the actual UPDATE SQL, it still opens a transaction, runs
all callbacks (including validation queries like `Contact Exists?`), and
triggers `after_commit` hooks — all for a no-op.

This adds a `changed?` guard before `save!` to skip it entirely when no
attributes have actually changed.

**How to test**

- Trigger an identify call via the SDK with a contact's existing
attributes (same name, email, custom_attributes, etc.)
- The contact should not fire a save (no transaction, no callbacks)
- Trigger an identify call with a changed attribute — save should work
normally

**What changed**

- `ContactIdentifyAction#update_contact`: guard `save!` with `changed?`
check
- Added specs to verify `save!` is skipped for unchanged params and
avatar job still enqueues independently
2026-03-11 21:40:38 -07:00
PranavandGitHub c6f82783ba chore: Remove message touch:true, use combined update query (#13770)
Removes touch: true from the belongs_to :conversation association on
Message and consolidates the conversation timestamp update into the
existing set_conversation_activity callback.

Previously, every message save triggered two separate UPDATE queries on
the conversation — one from Rails' touch (updating updated_at) and
another from set_conversation_activity (updating last_activity_at). This
combines both into a single update_columns call, reducing write load on
the conversations table on every message creation.

### What changed
- Removed touch: true from belongs_to :conversation in Message
- Added updated_at: created_at to the existing update_columns call in
set_conversation_activity
2026-03-11 07:31:46 -07:00
Tanmay Deep SharmaandGitHub 9b3f0029a4 fix: override minimatch to patch ReDoS vulnerability (#13769)
## Description

Remediates high severity ReDoS vulnerability in minimatch
(CVE-2026-27903) flagged by Vanta/Dependabot.
minimatch is a transitive dev-only dependency (via eslint and
tailwindcss build tooling) — not shipped to production. Added pnpm
overrides to force patched versions:
- minimatch@<4 → 3.1.5
- minimatch@>=9.0.0 <9.0.7 → 9.0.9

Closes:
https://linear.app/chatwoot/issue/CW-6595/vanta-remediate-high-vulnerabilities-identified-in-packages-are

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- No production impact — minimatch is only used in dev tooling, not at
runtime
- pnpm install completes successfully

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-11 16:48:48 +05:30
6e46be36c8 fix: Add fix to only allow confirmed agents to used in Agent Assingments at Macros/Automations (#13225)
# Pull Request Template

## Description

Unconfirmed agents (pending email verification) were incorrectly
appearing in the "assign agent" dropdown for macros and automations.
This fix filters out unconfirmed agents from these dropdowns and adds
backend validation to prevent assignment of unconfirmed agents.

Fixes #13223

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Backend tests:**
```bash
docker compose run --rm rails bundle exec rspec spec/services/action_service_spec.rb
```
- Added tests for confirmed agent assignment (should succeed)
- Added tests for unconfirmed agent assignment (should be skipped)

**Frontend tests:**
```bash
docker compose run --rm rails pnpm test app/javascript/dashboard/composables/spec/useMacros.spec.js
```
- Updated mocks to use `getVerifiedAgents` getter

**Manual testing:**
1. Create an unconfirmed agent via platform
2. Navigate to Settings → Macros → New Macro → Add "Assign Agent" action
3. Verify unconfirmed agent does NOT appear in dropdown
4. Navigate to Settings → Automations → New Automation → Add "Assign
Agent" action
5. Verify unconfirmed agent does NOT appear in dropdown

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-11 02:01:53 -07:00
Aakash BakhleandGitHub 87f5af4caa fix: playground captain v2 scenarios (#13747)
# Pull Request Template

## Description

Playground now uses v2. It was only wired to use v1. Traces get `source:
playground` on langfuse when playground has been used.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs
<img width="1806" height="1276" alt="image"
src="https://github.com/user-attachments/assets/41ef4eb3-52b1-4b8e-9a4f-e8510c90cb39"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-03-11 14:05:16 +05:30
dbe35252bc fix: Use handoff_key for scenarios (#13755)
# Pull Request Template

## Description

Ensure agent function names stay within OpenAI's 64-char limit
(ai-agents prepends "handoff_to_").

Add HANDOFF_TITLE_SLUG_MAX_LENGTH and
handoff_key generation: persisted records use `scenario_{id}_agent`; new
records use a truncated title slug.
Assistant scenario keys and agent_name now reference the generated
handoff key.

fixes :

`Invalid 'messages[9].tool_calls[0].function.name': string too long.
Expected a string with maximum length 64, but got a string with length
95 instead.`

## Type of change


- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

Tested locally
<img width="1806" height="1044" alt="image"
src="https://github.com/user-attachments/assets/40cd7a3d-3d97-43a8-bd56-d3f5d63abbda"
/>

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-03-11 14:01:25 +05:30
Tanmay Deep SharmaandGitHub de8aa48b83 feat: make assignment_v2 feature available to all accounts (#13764)
## Description

Makes the assignment_v2 feature flag available to all installations by
removing the chatwoot_internal restriction. Previously this feature was
hidden from self-hosted installations; this change surfaces it in the
feature flags UI so any Chatwoot instance can enable it.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-11 13:46:53 +05:30
Shivam MishraandGitHub 43977a1927 chore: upgrade packages to resolve dependency advisories (#13762)
This PR generally upgrades the available packages clearing existing
transient package vulnerabilities

### minimatch

This is mostly a dev-dependency, it was in the main dependency for
`@formkit/vue`, this PR clears it.
It still remains a dependency for tailwind and tailwind typograpghy, but
it's tolerable
_Fixes: CW-6594 CW-6592 CW-6598_


### Axios

Upgraded the dependency directly. Fixed it
_Fixes: CW-6591_

### dompurify

Upgraded the dependency, it's in the safe range now
_Fixes: CW-6611 CW-6610_

### ajv

Dev dependency, can be safely ignored
_Fixes: CW-6606 CW-6604_

### @tootallnate/once

Dev dependency, comes from Histoire, can be safely ignored
_Fixes: CW-6603_
2026-03-11 13:20:17 +05:30
Sivin VargheseandGitHub a9cabad529 chore: Hide reply-to when copilot is active (#13749) 2026-03-11 11:30:30 +05:30
Sojan JoseandGitHub fdc326094a docs(swagger): document account label endpoints (#13760)
Documents the missing account-level label CRUD endpoints in Chatwoot's
Swagger output so label management is discoverable alongside the
existing contact and conversation label APIs.

Fixes: none
Closes: none

Why
The account-level label API already exists in Chatwoot, but it was
missing from the published Swagger spec. That made label management
harder to discover even though contact and conversation label assignment
endpoints were already documented.

What this change does
- adds a `Labels` tag to the application Swagger docs
- adds the label resource and create/update payload schemas
- documents `GET/POST /api/v1/accounts/{account_id}/labels`
- documents `GET/PATCH/DELETE /api/v1/accounts/{account_id}/labels/{id}`
- regenerates the compiled Swagger JSON artifacts

Validation
- rebuilt the Swagger JSON from the source YAML
- verified the new label endpoints appear in `swagger/swagger.json`
- verified the new label endpoints appear in
`swagger/tag_groups/application_swagger.json`
- started the local Rails server and confirmed `/swagger` and
`/swagger/swagger.json` return `200 OK`
2026-03-10 22:24:16 -07:00
9a9398b386 feat: validate OpenAPI spec using Skooma (#13623)
Adds Skooma-based OpenAPI validation so SDK-facing request specs can
assert that documented request and response contracts match real Rails
behavior. This also upgrades the spec to OpenAPI 3.1 and fixes contract
drift uncovered while validating core application and platform
resources.

Closes
None

Why
We want CI to catch OpenAPI drift before it reaches SDK consumers. While
wiring validation in, this PR surfaced several mismatches between the
documented contract and what the Rails endpoints actually accept or
return.

What this change does
- Adds Skooma-backed OpenAPI validation to the request spec flow and a
dedicated OpenAPI validation spec.
- Migrates nullable schema definitions to OpenAPI 3.1-compatible unions.
- Updates core SDK-facing schemas and payloads across accounts,
contacts, conversations, inboxes, messages, teams, reporting events, and
platform account resources.
- Documents concrete runtime cases that were previously missing or
inaccurate, including nested `profile` update payloads, multipart avatar
uploads, required profile update bodies, nullable inbox feature flags,
and message sender types that include both `Captain::Assistant` and
senderless activity-style messages.
- Regenerates the committed Swagger JSON and tag-group artifacts used by
CI sync checks.

Validation
- `bundle exec rake swagger:build`
- `bundle exec rspec spec/swagger/openapi_spec.rb`

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-10 18:33:55 -07:00
dc0e5eb465 fix: optimize message query with account_id filter (#13759)
## Description

This PR optimizes message queries by explicitly filtering with
`account_id` so the database can use the existing indexes more
efficiently.

Changes:
- Add `account_id` to message query filters to improve index
utilization.
- Update `last_incoming_message` query to include `account_id`.
- Avoid unnecessary preloading of `contact_inboxes` where it is not
required.
- Update specs to ensure `account_id` is set correctly in
message-related tests.

These changes reduce query cost and improve performance for message
lookups, especially on large accounts.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-03-10 16:46:20 -07:00
PranavandGitHub 79218be5c4 fix: Force account_id to use index on messages query on conversation push_event_data (#13757)
Add a where clause. where(account_id: account_id).
2026-03-11 01:03:18 +05:30
Shivam MishraandGitHub 8d9dd99012 fix: scenario label (#13746) 2026-03-10 18:32:44 +05:30
9f376c43b5 fix(signup): normalize account signup config checks (#13745)
This makes account signup enforcement consistent when signup is disabled
at the installation level. Email signup and Google signup now stay
blocked regardless of whether the config value is stored as a string or
a boolean.

This effectively covers the config-loader path, where `YAML.safe_load`
reads `value: false` from `installation_config.yml` as a native boolean
and persists it that way.

- Normalized the account signup check so disabled signup is handled
consistently across config value types.
- Reused the same check across API signup and Google signup entry
points.
- Added regression coverage for the disabled-signup cases in the
existing controller specs.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-03-10 16:35:09 +05:30
824164852c refactor: extract custom attribute methods from FilterService (#13743)
- Extracted 6 custom attribute methods (`custom_attribute_query`,
`attribute_model`, `attribute_data_type`, `build_custom_attr_query`,
`custom_attribute`, `not_in_custom_attr_query`) into a new
`Filters::CustomAttributeFilterHelper` module.
- Added an inline `rubocop:disable` for the intentional
`Lint/ShadowedException` in `coerce_lt_gt_value` — `Date::Error` is a
subclass of `ArgumentError`, but both are listed explicitly for clarity.

## Why `app/services/filters/`

The existing `Filters::FilterHelper` lives in `app/helpers/filters/`,
but that location triggers `Rails/HelperInstanceVariable` for any module
that uses instance variables. The extracted methods share state with
`FilterService` via instance variables (`@attribute_key`, `@account`,
`@custom_attribute`, etc.), so placing them in `app/helpers/` would
require a cop disable.

`app/services/filters/` is a better fit because:
- The module is a service mixin, not a view helper — it's only included
by `FilterService` and its subclasses (`Conversations::FilterService`,
`Contacts::FilterService`, `AutomationRules::ConditionsFilterService`).
- It sits alongside the services that use it.
- No cop disables needed.

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-03-10 14:15:52 +05:30
8ea93ec73d chore(docs): Update documentation for messages API (#13744)
Update the documentation for messages API

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-03-10 14:15:10 +05:30
Vishnu NarayananandGitHub 28f58b3694 fix: make conversation transcript rate limit configurable (#13740)
## Summary
- The conversation transcript endpoint rate limit is hardcoded at 30
requests/hour per account with no way to override it
- Self-hosted users with active accounts hit this limit and get 429
errors across all channels
- Add `RATE_LIMIT_CONVERSATION_TRANSCRIPT` env var (default: `1000`) to
make it configurable, consistent with other throttles like
`RATE_LIMIT_CONTACT_SEARCH` and `RATE_LIMIT_REPORTS_API_ACCOUNT_LEVEL`
2026-03-10 14:11:36 +05:30
Sojan JoseandGitHub 52cd70dfa3 fix(super-admin): prefill confirmed_at in new user form (#13662)
On self-hosted instances without email configured, users created from
Super Admin can get stuck in an unconfirmed state. This PR implements
the default at the Super Admin frontend form layer, not in backend
creation logic.

What changed:
- Added a custom `ConfirmedAtField` for Super Admin user forms.
- Prefills `confirmed_at` with current time on the **New User** form
(`GET /super_admin/users/new`).
- Kept backend create behavior unchanged
(`resource_class.new(resource_params)`), so API/manual payloads still
behave normally.

Behavior:
- In Super Admin UI, `confirmed_at` is prefilled by default.
- If someone wants an unconfirmed user, they can clear the
`confirmed_at` field before saving.
- If `confirmed_at` is omitted from payload entirely, the created user
remains unconfirmed.

Scope note: external signup flows are intentionally unchanged in this PR
(`/api/v1/accounts`, `/api/v2/accounts`, and social/omniauth signup
behavior are not modified).

## Demo 





https://github.com/user-attachments/assets/436abbb0-d4cf-49a6-a1b8-4b6aa85aa09f
2026-03-10 12:14:58 +05:30
Shivam Mishra 19683fae74 Merge branch 'hotfix/4.11.2' into develop
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
2026-03-09 21:20:08 +05:30
Shivam Mishra 432462f967 feat: harden filter service 2026-03-09 21:19:20 +05:30
Sojan JoseandGitHub 9e40431d3a feat: show MFA status on Super Admin user page (#13724)
This PR adds an MFA row to the individual Super Admin user page and
shows the current state as Enabled or Disabled with a compact status
badge.

Fixes #13723

## Screens

<img width="1370" height="1043" alt="image"
src="https://github.com/user-attachments/assets/b9fee284-43b7-4bbb-9f60-b71ab34b96b7"
/>


<img width="1370" height="1043" alt="image"
src="https://github.com/user-attachments/assets/23c5e6d3-24b8-40d2-9134-0c2b1dc98b41"
/>
2026-03-09 08:04:36 -07:00
Vishnu NarayananandGitHub 4576e75a67 fix: bump redis-client to 0.26.4 to fix Sentinel resolution (#13689)
Description:
  ## Summary

- `redis-client` 0.22.2 uses `.call()` during Sentinel master
resolution, but `redis-rb` 5.x undefines `.call()` (only `.call_v()`
  exists), causing Sentinel connections to fail.
- Bumps `redis-client` from 0.22.2 to 0.26.4 which includes the upstream
fix (redis-rb/redis-client#283).
- Also bumps transitive dependency `connection_pool` from 2.5.3 to
2.5.5.

  Fixes #11665 https://github.com/chatwoot/chatwoot/issues/8368

  ## Test

  - `bundle exec rspec spec/lib/redis/config_spec.rb` passes
  - Full CI suite passes
2026-03-09 20:03:01 +05:30
Tanmay Deep SharmaandGitHub 11826e2a21 perf: reduce presence update frequency and fix background tab throttling (#13726)
## Description
Reduces the frequency of update_presence WebSocket calls from the live
chat widget and fixes agents appearing offline when the dashboard is in
a background tab.

## Fixes # (issue)
https://github.com/chatwoot/chatwoot/issues/13720

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-09 18:23:44 +05:30
Sivin VargheseandGitHub f4e6aa1bd2 fix: ProseMirror prompt modal UI issue (#13722) 2026-03-09 16:51:49 +05:30
Muhsin KelothandGitHub 939471cb3b fix: Prevent duplicate conversations in conversation list (#13713)
Agents using API channel inboxes (e.g., WhatsApp Automate) reported
seeing the same conversation appear twice in their conversation list —
one showing the last message preview and the other showing "No
Messages". Backend investigation confirmed no duplicate conversations
exist in the database, making this purely a frontend issue.

The root cause is a race condition in WebSocket event delivery. When a
conversation is created via the API with auto-assignment, the backend
enqueues multiple ActionCable broadcast jobs (`conversation.created`,
`assignee.changed`, `team.changed`) within milliseconds of each other.
In production with multi-threaded Sidekiq workers, these events can
arrive at the frontend out of order. If `assignee.changed` arrives
before `conversation.created`, the `UPDATE_CONVERSATION` mutation pushes
the conversation into the store (since it doesn't exist yet), and then
`ADD_CONVERSATION` blindly pushes it again — resulting in a duplicate
entry.

The fix adds a uniqueness check in the `ADD_CONVERSATION` mutation to
skip the push if a conversation with the same ID already exists in the
store, matching the dedup pattern already used by
`SET_ALL_CONVERSATION`.
2026-03-06 14:07:02 +04:00
Sivin VargheseandGitHub 88587b1ccb feat: Add natural language date parser for snooze functionality (#13587)
# Pull Request Template

## Description

This PR introduces a custom, lightweight natural-language date parser
(dependency-free except for date-fns) to power snooze actions via the
command bar (e.g., “Remind me tomorrow at 6am”). It also adds support
for multi-language searches.



<details>
  <summary>Supported Formats</summary>

## Snooze Date Parser — Supported Input Formats


## 1. Durations

Specify an amount of time from now.

### Basic

- `5 minutes` · `2 hours` · `3 days` · `1 week` · `6 months` · `ten
year`
- `in 2 hours` · `in 30 minutes` · `in a week` · `in a month`
- `5 minutes from now` · `a week from now` · `two weeks from now`

### Half / fractional

- `half hour` · `half day` · `half week` · `half month`
- `in half a day` · `in half an hour` · `in half a week`
- `one and a half hours` · `in one and a half hours`
- `1.5 hours` · `2.5 days`

### Compound

- `1 hour and 30 minutes` · `2 hours and 15 minutes`
- `2 days at 3pm` · `1 week at 9am`

### Shorthand (no spaces)

- `2h` · `30m` · `1h30m` · `2h15m`
- `1h30minutes` · `2hr15min` · `1hour30min`

### Informal quantities

- `couple hours` · `a couple of days` · `in a couple hours`
- `a few minutes` · `in a few hours` · `in a few days`
- `fortnight` · `in a fortnight` _(= 2 weeks)_

### Trailing "later"

- `2 days later` · `a week later` · `month later`

### Prefix words (`after` / `within`)

- `after 2 hours` · `after 3 days` · `after ten year`
- `within a week` · `within 2 hours`

### Recognised word-numbers

`a` (1) · `an` (1) · `one` – `twenty` · `thirty` · `forty` · `fifty` ·
`sixty` · `ninety` · `half` (0.5) · `couple` (2) · `few` (3)

---

## 2. Relative Days

- `today` · `tonight` · `tomorrow`
- `tomorrow morning` · `tomorrow afternoon` · `tomorrow evening` ·
`tomorrow night`
- `tomorrow at 3pm` · `tomorrow 9` · `tonight at 8` · `tonight at 10pm`
- `tomorrow same time` · `same time tomorrow`
- `day after tomorrow` · `the day after tomorrow` · `day after tomorrow
at 2pm`
- `later today` · `later this afternoon` · `later this evening`

---

## 3. Weekdays

- `monday` · `friday` · `wed` · `thu`
- `friday at 3pm` · `monday 9am` · `wednesday 14:30`
- `monday morning` · `friday afternoon` · `wednesday evening`
- `monday morning 6` · `friday evening 7`
- `this friday` · `upcoming monday` · `coming friday`
- `same time friday` · `same time wednesday`

---

## 4. "Next" Patterns

- `next hour` · `next week` · `next month` · `next year`
- `next week at 2pm` · `next month at 9am`
- `next monday` · `next friday` · `next friday at 3pm`
- `next monday morning` · `next friday evening`
- `monday of next week` · `next week monday`
- `next january` · `next december`
- `next business day` · `next working day`

---

## 5. Time of Day

- `morning` · `afternoon` · `evening` · `night` · `noon` · `midnight`
- `this morning` · `this afternoon` · `this evening`
- `early morning` · `late evening` · `late night`
- `morning at 8am` · `evening 6pm` · `afternoon 2pm`
- `eod` · `end of day` · `end of the day`

---

## 6. Standalone Time

- **12-hour:** `3pm` · `9am` · `at 3pm` · `at 9:30am`
- **24-hour:** `14:30` · `at 14:30`

---

## 7. Named Dates (Month + Day)

- `jan 15` · `january 15` · `march 20` · `dec 25`
- `jan 1st` · `march 3rd` · `april 2nd` · `december 31st`
- `15 march` · `25 dec` _(reversed order)_
- `jan 15 2025` · `dec 25 2025` · `march 20 next year`
- `jan 15 at 2pm` · `march 5 at 2pm`
- `december 2025` · `january 2024` _(month + year only)_

---

## 8. Month + Ordinal Patterns

Target a specific week or day within a month.

### Week of month

- `april first week` · `july 2nd week` · `feb 3rd week`
- `first week of april` · `2nd week of july`

### Day of month

- `april first day` · `march second day` · `march 5th day`
- `third day of march` · `5th day of jan at 2pm`

### Supported ordinals

- **Digit:** `1st` `2nd` `3rd` `4th` `5th` … (up to 31 for days, 5 for
weeks)
- **Word:** `first` `second` `third` `fourth` `fifth` `sixth` `seventh`
`eighth` `ninth` `tenth`

---

## 9. Formal / Numeric Dates

- **ISO:** `2025-01-15`
- **Slash (M/D/Y):** `01/15/2025`
- **Dash (D-M-Y):** `15-01-2025`
- **Dot (D.M.Y):** `15.01.2025`
- Any of the above **+ time:** `2025-01-15 at 3pm`

---

## 10. Special Phrases

- `this weekend` · `weekend` · `next weekend`
- `end of week` · `end of month`
- `end of next week` · `end of next month`
- `beginning of next week` · `start of next week`
- `beginning of next month`

---

## 11. Noise / Filler Stripping

The parser silently removes conversational prefixes so all of these work
exactly the same as the bare expression:

```
snooze for 2 hours          →  2 hours
remind me tomorrow          →  tomorrow
please snooze until friday  →  friday
can you set a reminder for next week  →  next week
schedule this for jan 15    →  jan 15
postpone to next monday     →  next monday
defer for 2 days            →  2 days
delay it by 1 hour          →  1 hour
```

### Recognised filler verbs / prefixes

`snooze` · `remind` · `remind me` · `set a reminder` · `add a reminder`
·
`schedule` · `postpone` · `defer` · `delay` · `push`

### Recognised prepositions (stripped)

`on` · `to` · `for` · `at` · `until` · `till` · `by` · `from` · `after`
· `within`

### Typo corrections

`tommorow` / `tommorrow` → `tomorrow` · `nxt` → `next`

---

## 12. Multi-Language Support

The parser supports localised input via translations in `snooze.json`.

### Translatable token categories

- **Units:** minute, hour, day, week, month, year _(singular + plural)_
- **Relative days:** tomorrow, day after tomorrow, next week / month,
this / next weekend
- **Time of day:** morning, afternoon, evening, night, noon, midnight
- **Word numbers:** one through ten, twelve, fifteen, twenty, thirty
- **Ordinals:** first through fifth
- **Structural words:** at, in, of, after, week, day, from now, next
year
- **Meridiem:** am, pm

### Auto-detected from locale

Weekday names and month names are resolved automatically via
`Intl.DateTimeFormat` for the user's locale — no manual translation
needed.

</details>

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

**Screenshots**
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/c690d328-a0df-41d2-b531-2b4e6ce6b5fd"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/fa881acc-4fed-4ba3-9166-58bd953bcb26"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/4d9a224b-641c-409c-a7ce-3dec2b5355e2"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/465b9835-d82c-4bc7-a2ae-94976ada2d3b"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/839fe8fc-8943-4b66-83ca-5c61c95f24d8"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/3a9a54f2-7669-40f2-b098-a3f5c183526d"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/7791ab2b-c763-49a9-90a0-e91b0d8f0a26"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/4689390c-0e7f-48ae-acc7-d8e28695452f"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/d0aa5217-d0e1-4f41-b663-72888d028a3a"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/4fa9ff5b-a874-43d5-812f-6abe1a95a5ac"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/2c8199a6-f872-46af-986f-bdf8597248f5"
/>
<img width="974" height="530" alt="image"
src="https://github.com/user-attachments/assets/5bd9effc-7518-4f96-b2f2-7c547f32f500"
/>




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-06 12:20:22 +04:00
598ece9a2d fix: Handle Facebook reel attachment type (#13691)
Fixes https://linear.app/chatwoot/issue/PLA-96/argumenterror-reel-is-not-a-valid-file-type-argumenterror
Fixes a crash in `when a user shares a Facebook reel via Messenger.
Facebook sends these attachments with `type: "reel"`, which isn't a
valid `file_type` enum value on the Attachment model (only `ig_reel`
exists, matching Instagram's format).

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-06 08:49:41 +04:00
059506b1db feat: Add automatic favicon fetching for companies (#13013)
## Summary

This Enterprise-only feature automatically fetches a favicon for
companies created with a domain, and adds a batch task to backfill
missing avatars for existing companies. The flow only targets companies
that do not already have an attached avatar, so existing avatars are
left untouched.


## Demo 



https://github.com/user-attachments/assets/d050334e-769f-4e46-b6e7-f7423727a192



## What changed

- Added `Avatar::AvatarFromFaviconJob` to build a Google favicon URL
from the company domain and fetch it through `Avatar::AvatarFromUrlJob`
- Triggered favicon fetching from `Company` with `after_create_commit`
- Added `Companies::FetchAvatarsJob` to batch existing companies that
are missing avatars
- Added `companies:fetch_missing_avatars` under `enterprise/lib/tasks`
- Kept the company-specific implementation inside the Enterprise
boundary
- Stubbed the new favicon request in unrelated specs that now hit this
callback indirectly
- Updated a couple of CI-sensitive specs that were failing due to
callback side effects / reload-safe exception assertions

## How to verify

1. Create a company in Enterprise with a valid domain and no avatar.
2. Confirm that a favicon-based avatar gets attached shortly after
creation.
3. Create another company with a domain and an avatar already attached.
4. Confirm that the existing avatar is not replaced.
5. Run `companies:fetch_missing_avatars`.
6. Confirm that existing companies without avatars get one, while
companies that already have avatars remain unchanged.

## Notes

- This change does not refresh or overwrite existing company avatars
- Favicon fetching only runs for companies with a present domain
- The branch includes the latest `develop`

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-05 18:51:28 -08:00
Sojan JoseandGitHub 397b0bcc9d feat: allow agent bots to toggle typing status (#13705)
Agent bot conversations now feel more natural because AgentBot tokens
can toggle typing status, so end users see a live typing indicator in
the widget while the bot is preparing a reply. This keeps the
interaction responsive and human-like without weakening token
authorization boundaries.

## Closes
- https://github.com/chatwoot/chatwoot/issues/8928
- https://linear.app/chatwoot/issue/CW-5205

## How to test
1. Open the widget and start a conversation as a customer.
2. Connect an AgentBot to the same inbox.
3. Trigger `toggle_typing_status` with the AgentBot token
(`typing_status: on`).
4. Confirm the customer sees the typing indicator in the widget.
5. Trigger `toggle_typing_status` with `typing_status: off` and confirm
the indicator disappears.

## What changed
- Added `toggle_typing_status` to bot-accessible conversation endpoints.
- Restricted bot-accessible endpoint usage to `AgentBot` token owners
only (non-user tokens like `PlatformApp` remain unauthorized).
- Updated typing status flow to preserve AgentBot identity in
dispatch/broadcast paths.
- Added request coverage for AgentBot success and PlatformApp
unauthorized behavior.
- Added Swagger documentation for `POST
/api/v1/accounts/{account_id}/conversations/{conversation_id}/toggle_typing_status`
and regenerated swagger artifacts.
2026-03-05 08:13:52 -08:00
fd69b4c8f2 fix: captain json parsing (#13708)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-03-05 15:43:21 +05:30
Sivin VargheseandGitHub 3ea5f258a4 fix: Use page_title with fallback to name for portal display titles (#13719) 2026-03-05 14:20:31 +05:30
Sojan JoseandGitHub 42a244369d feat(help-center): enable drag-and-drop category reordering (#13706) 2026-03-05 12:53:38 +05:30
3abe32a2c7 chore(dev): add cleanup flow to force_run in Makefile (#13093)
## Summary

Improve local dev restart reliability by enhancing `make force_run` to
run cleanup before starting Overmind.

## How To Reproduce

During local development, if `make run` is interrupted (for example with
Ctrl-C), stale state can remain (`.overmind.sock`, PID files, and
processes on ports `3000`/`3036`), which can block or complicate the
next restart.

## Changes

Updated `force_run` in `Makefile` to:
- print cleanup start/end messages
- kill processes on ports `3036` and `3000` (best-effort)
- remove `.overmind.sock`
- remove `tmp/pids/*.pid`
- then start `Procfile.dev` via Overmind

No other files are changed in this PR.

## Testing

- Verified branch diff against `develop` only touches `Makefile`.
- Ran `make -n force_run` to validate the command sequence and startup
flow.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-04 17:56:55 -08:00
Sivin VargheseandGitHub f24e7eb231 fix: Missing required prop warning in account settings page (#13711)
# Pull Request Template

## Description

This PR fixes the console warning in development: `[Vue warn]: Missing
required prop: "name"` on the account settings page.


## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Screenshot**
<img width="599" height="1036" alt="image"
src="https://github.com/user-attachments/assets/b0b45854-4cfb-4fe7-ab14-c42a65c523df"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-04 19:58:47 +04:00
8cfbb75128 fix: add missing V1 guardrails to V2 assistant prompt (#13701)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-03-03 17:36:49 +05:30
Muhsin KelothandGitHub a1b98a253c fix(ui): Show delivered state for Instagram external echo messages (#13700)
Instagram external echo messages were being saved with status:
delivered, but the message meta UI did not treat Instagram as a channel
eligible for delivered-state rendering. As a result, these messages fell
back to progress and showed as “Sending”. This change updates the
message status mapping in the new message UI to include Instagram in the
delivered-state condition.
2026-03-03 15:16:53 +04:00
Aakash BakhleandGitHub 374d2258c7 fix: captain talking over support agent (#13673) 2026-03-03 16:13:34 +05:30
Sivin VargheseandGitHub 89da4a2292 feat: compose form improvements (#13668) 2026-03-02 18:27:51 +05:30
Muhsin KelothandGitHub 9aacc0335b feat(facebook): use HUMAN_AGENT tag for Messenger replies when human-agent config is enabled (#13690)
This PR updates Facebook Messenger outbound tagging in Chatwoot to
support Human Agent messaging when enabled.

Previously, Facebook outbound text and attachment messages were always
sent with:

```
messaging_type: MESSAGE_TAG
tag: ACCOUNT_UPDATE
```
With this change, the tag is selected dynamically:

```
HUMAN_AGENT when ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT is enabled
ACCOUNT_UPDATE as fallback when the flag is disabled
```
2026-03-02 15:32:59 +04:00
ab93821d2b fix(agent-bot): stabilize webhook delivery for transient upstream failures (#13521)
This fixes the agent-bot webhook delivery path so transient upstream
failures follow the expected delivery lifecycle. Existing fallback
behavior is preserved, and fallback actions are applied only after
delivery attempts are exhausted.

To reproduce, configure an agent-bot webhook endpoint to return 429/500
for message events. Before this fix, failure handling could be applied
too early; after this fix, delivery attempts complete first and then
existing fallback handling runs.

Tested with:
- bundle exec rspec spec/jobs/agent_bots/webhook_job_spec.rb
spec/lib/webhooks/trigger_spec.rb
- bundle exec rubocop spec/jobs/agent_bots/webhook_job_spec.rb
spec/lib/webhooks/trigger_spec.rb

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-03-02 14:18:29 +04:00
Shivam MishraandGitHub 8d48e05283 feat: reclaim mobile_v2 flag for report_rollup (#13666) 2026-03-02 13:12:42 +05:30
Shivam Mishra a08125e283 Merge branch 'hotfix/4.11.1'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-02-20 20:02:18 +05:30
1832 changed files with 54343 additions and 10610 deletions
+2 -2
View File
@@ -93,8 +93,8 @@ jobs:
exit 1
fi
mkdir -p ~/tmp
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
# Bundle audit
- run:
+9
View File
@@ -68,6 +68,15 @@
- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages
## PR Description Format
- Start with a short, user-facing paragraph describing the product change.
- Add a `Closes` section with relevant issue links (GitHub, Linear, etc.).
- For feature PRs, add `How to test` from a product/UX standpoint.
- For bugfix PRs, use `How to reproduce` when helpful.
- Optionally add a `What changed` section for implementation highlights.
- Do not add a `How this was tested` section listing specs/commands.
## Project-Specific
- **Translations**:
+1
View File
@@ -268,6 +268,7 @@ group :development, :test do
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '>= 0.21', require: false
gem 'skooma'
gem 'spring'
gem 'spring-watcher-listen'
end
+13 -2
View File
@@ -191,7 +191,7 @@ GEM
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
rexml
@@ -473,6 +473,12 @@ GEM
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
json_skooma (0.2.5)
bigdecimal
hana (~> 1.3)
regexp_parser (~> 2.0)
uri-idna (~> 0.2)
zeitwerk (~> 2.6)
judoscale-rails (1.8.2)
judoscale-ruby (= 1.8.2)
railties
@@ -736,7 +742,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.2)
redis-client (0.26.4)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -910,6 +916,9 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
skooma (0.3.7)
json_skooma (~> 0.2.5)
zeitwerk (~> 2.6)
slack-ruby-client (2.7.0)
faraday (>= 2.0.1)
faraday-mashify
@@ -970,6 +979,7 @@ GEM
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1143,6 +1153,7 @@ DEPENDENCIES
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
skooma
slack-ruby-client (~> 2.7.0)
spring
spring-watcher-listen
+6 -2
View File
@@ -40,8 +40,12 @@ run:
fi
force_run:
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
@echo "Cleaning up Overmind processes..."
@lsof -ti:3036 2>/dev/null | xargs kill -9 2>/dev/null || true
@lsof -ti:3000 2>/dev/null | xargs kill -9 2>/dev/null || true
@rm -f ./.overmind.sock
@rm -f tmp/pids/*.pid
@echo "Cleanup complete"
overmind start -f Procfile.dev
force_run_tunnel:
+1 -1
View File
@@ -1 +1 @@
4.11.1
4.11.2
+1 -1
View File
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
@contact.save! if @contact.changed?
enqueue_avatar_job
end
@@ -2,12 +2,17 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
# This check handles very rare case if there are multiple files to attach with only one usupported file
# This check handles very rare case if there are multiple files to attach with only one unsupported file
return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
params = attachment_params(attachment)
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
if facebook_reel?(attachment)
update_facebook_reel_content(attachment)
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
@@ -26,7 +31,7 @@ class Messages::Messenger::MessageBuilder
end
def attachment_params(attachment)
file_type = attachment['type'].to_sym
file_type = normalize_file_type(attachment['type'])
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
@@ -100,6 +105,28 @@ class Messages::Messenger::MessageBuilder
private
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
attachment['type'].to_sym == :reel
end
def update_facebook_reel_content(attachment)
url = attachment.dig('payload', 'url')
return if url.blank?
@message.update!(content: url) if @message.content.blank?
end
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
end
@@ -40,7 +40,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def reorder
Article.update_positions(params[:positions_hash])
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
@@ -1,7 +1,7 @@
class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_category, except: [:index, :create]
before_action :fetch_category, except: [:index, :create, :reorder]
before_action :set_current_page, only: [:index]
def index
@@ -32,6 +32,11 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
head :ok
end
def reorder
Category.update_positions(portal: @portal, positions_hash: params[:positions_hash])
head :ok
end
private
def fetch_category
@@ -39,7 +44,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def related_categories_records
@@ -0,0 +1,55 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
@@ -201,7 +201,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
contact_scope = Current.account.contacts
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
@contact = contact_scope.find(params[:id])
end
def process_avatar_from_url
@@ -107,7 +107,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def toggle_typing_status
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, current_user, params)
typing_status_manager = ::Conversations::TypingStatusManager.new(@conversation, Current.user, params)
typing_status_manager.toggle_typing_status
head :ok
end
@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
before_action :check_authorization, except: [:show]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
@@ -100,7 +100,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
contact = @contact
end
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
identify_contact(contact)
end
@@ -58,7 +58,7 @@ class Api::V2::AccountsController < Api::BaseController
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def validate_captcha
@@ -1,6 +1,6 @@
module AccessTokenAuthHelper
BOT_ACCESSIBLE_ENDPOINTS = {
'api/v1/accounts/conversations' => %w[toggle_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations' => %w[toggle_status toggle_typing_status toggle_priority create update custom_attributes],
'api/v1/accounts/conversations/messages' => ['create'],
'api/v1/accounts/conversations/assignments' => ['create']
}.freeze
@@ -28,7 +28,7 @@ module AccessTokenAuthHelper
def validate_bot_access_token!
return if Current.user.is_a?(User)
return if agent_bot_accessible?
return if @resource.is_a?(AgentBot) && agent_bot_accessible?
render_unauthorized('Access to this endpoint is not authorized for bots')
end
@@ -51,8 +51,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def account_signup_allowed?
# set it to true by default, this is the behaviour across the app
GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') != 'false'
GlobalConfigService.account_signup_enabled?
end
def resource_class(_mapping = nil)
+1 -1
View File
@@ -25,7 +25,7 @@ class UserDashboard < Administrate::BaseDashboard
current_sign_in_ip: Field::String,
last_sign_in_ip: Field::String,
confirmation_token: Field::String,
confirmed_at: Field::DateTime,
confirmed_at: ConfirmedAtField,
confirmation_sent_at: Field::DateTime,
unconfirmed_email: Field::String,
name: Field::String.with_options(searchable: true),
+4
View File
@@ -0,0 +1,4 @@
require 'administrate/field/base'
class ConfirmedAtField < Administrate::Field::DateTime
end
+9 -5
View File
@@ -47,11 +47,15 @@ module Filters::FilterHelper
def handle_additional_attributes(query_hash, filter_operator_value, data_type)
if data_type == 'text_case_insensitive'
"LOWER(#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}') " \
"#{filter_operator_value} #{query_hash[:query_operator]}"
ActiveRecord::Base.sanitize_sql_array(
["LOWER(#{filter_config[:table_name]}.additional_attributes ->> ?) #{filter_operator_value} #{query_hash[:query_operator]}",
query_hash[:attribute_key]]
)
else
"#{filter_config[:table_name]}.additional_attributes ->> '#{query_hash[:attribute_key]}' " \
"#{filter_operator_value} #{query_hash[:query_operator]} "
ActiveRecord::Base.sanitize_sql_array(
["#{filter_config[:table_name]}.additional_attributes ->> ? #{filter_operator_value} #{query_hash[:query_operator]} ",
query_hash[:attribute_key]]
)
end
end
@@ -70,7 +74,7 @@ module Filters::FilterHelper
def date_filter(current_filter, query_hash, filter_operator_value)
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::#{current_filter['data_type']} " \
"#{filter_operator_value}#{current_filter['data_type']} #{query_hash[:query_operator]}"
"#{filter_operator_value} #{query_hash[:query_operator]}"
end
def text_case_insensitive_filter(query_hash, filter_operator_value)
+2 -2
View File
@@ -57,14 +57,14 @@ class ContactAPI extends ApiClient {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
search(search = '', page = 1, sortAttr = 'name', label = '') {
search(search = '', page = 1, sortAttr = 'name', label = '', options = {}) {
let requestURL = `${this.url}/search?${buildContactParams(
page,
sortAttr,
label,
search
)}`;
return axios.get(requestURL);
return axios.get(requestURL, { signal: options.signal });
}
active(page = 1, sortAttr = 'name') {
@@ -25,6 +25,12 @@ class CategoriesAPI extends PortalsAPI {
delete({ portalSlug, categoryId }) {
return axios.delete(`${this.url}/${portalSlug}/categories/${categoryId}`);
}
reorder({ portalSlug, reorderedGroup }) {
return axios.post(`${this.url}/${portalSlug}/categories/reorder`, {
positions_hash: reorderedGroup,
});
}
}
export default new CategoriesAPI();
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
}
export default new InboxHealthAPI();
@@ -68,7 +68,19 @@ describe('#ContactsAPI', () => {
it('#search', () => {
contactAPI.search('leads', 1, 'date', 'customer-support');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: undefined }
);
});
it('#search with signal', () => {
const controller = new AbortController();
contactAPI.search('leads', 1, 'date', 'customer-support', {
signal: controller.signal,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support',
{ signal: controller.signal }
);
});
@@ -8,5 +8,6 @@ describe('#BulkActionsAPI', () => {
expect(categoriesAPI).toHaveProperty('create');
expect(categoriesAPI).toHaveProperty('update');
expect(categoriesAPI).toHaveProperty('delete');
expect(categoriesAPI).toHaveProperty('reorder');
});
});
@@ -1,4 +1,5 @@
<script setup>
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
@@ -28,7 +29,11 @@ const handleButtonClick = () => {
{{ headerTitle }}
</span>
<div
v-on-clickaway="() => emit('close')"
v-on-click-outside="[
() => emit('close'),
// This will prevent closing the modal when the editor Create link popup is open
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative group/campaign-button"
>
<Button
@@ -28,7 +28,7 @@ const props = defineProps({
medium: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits(['update:modelValue', 'executeCopilotAction']);
const slots = useSlots();
@@ -113,6 +113,9 @@ watch(
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@execute-copilot-action="
(...args) => emit('executeCopilotAction', ...args)
"
/>
<div
v-if="showCharacterCount || slots.actions"
@@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
<EmptyStateLayout
:title="$t('HELP_CENTER.TITLE')"
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
class="bg-n-surface-1"
>
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4 p-px">
@@ -58,18 +58,22 @@ const openArticle = id => {
}
};
const onReorder = reorderedGroup => {
store.dispatch('articles/reorder', {
reorderedGroup,
portalSlug: route.params.portalSlug,
});
const onReorder = async reorderedGroup => {
try {
await store.dispatch('articles/reorder', {
reorderedGroup,
portalSlug: route.params.portalSlug,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
}
};
const onDragEnd = () => {
// Reuse existing positions to maintain order within the current group
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedArticlePositions = localArticles.value
.map(article => article.position)
.sort((a, b) => a - b); // Use custom sort to handle numeric values correctly
.map((article, index) => article.position || index + 1)
.sort((a, b) => a - b);
const orderedArticles = localArticles.value.map(article => article.id);
@@ -98,6 +98,17 @@ const handleAction = ({ action, id, category: categoryData }) => {
deleteCategory(categoryData);
}
};
const reorderCategories = async reorderedGroup => {
try {
await store.dispatch('categories/reorder', {
portalSlug: route.params.portalSlug,
reorderedGroup,
});
} catch {
useAlert(t('HELP_CENTER.REORDER_CATEGORY.API.ERROR_MESSAGE'));
}
};
</script>
<template>
@@ -122,6 +133,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
:categories="categories"
@click="openCategoryArticles"
@action="handleAction"
@reorder="reorderCategories"
/>
<CategoryEmptyState
v-else
@@ -1,14 +1,22 @@
<script setup>
import { computed, ref, watch } from 'vue';
import Draggable from 'vuedraggable';
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
defineProps({
const props = defineProps({
categories: {
type: Array,
required: true,
},
});
const emit = defineEmits(['click', 'action']);
const emit = defineEmits(['click', 'action', 'reorder']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return localCategories.value?.length > 1;
});
const handleClick = slug => {
emit('click', slug);
@@ -17,21 +25,57 @@ const handleClick = slug => {
const handleAction = ({ action, value, id }, category) => {
emit('action', { action, value, id, category });
};
const onDragEnd = () => {
// Collect and sort existing positions, falling back to index+1 for null/0 values
const sortedPositions = localCategories.value
.map((category, index) => category.position || index + 1)
.sort((a, b) => a - b);
const reorderedGroup = localCategories.value.reduce(
(obj, category, index) => {
obj[category.id] = sortedPositions[index];
return obj;
},
{}
);
emit('reorder', reorderedGroup);
};
watch(
() => props.categories,
newCategories => {
localCategories.value = newCategories;
},
{ deep: true }
);
</script>
<template>
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
<CategoryCard
v-for="category in categories"
:id="category.id"
:key="category.id"
:title="category.name"
:icon="category.icon"
:description="category.description"
:articles-count="category.meta.articles_count || 0"
:slug="category.slug"
@click="handleClick(category.slug)"
@action="handleAction($event, category)"
/>
</ul>
<Draggable
v-model="localCategories"
:disabled="!dragEnabled"
item-key="id"
tag="ul"
role="list"
class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2"
@end="onDragEnd"
>
<template #item="{ element }">
<li class="list-none">
<CategoryCard
:id="element.id"
:title="element.name"
:icon="element.icon"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
:class="{ 'cursor-grab': dragEnabled }"
@click="handleClick(element.slug)"
@action="handleAction($event, element)"
/>
</li>
</template>
</Draggable>
</template>
@@ -12,7 +12,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
searchContacts,
createContactSearcher,
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
@@ -39,6 +39,7 @@ const props = defineProps({
const emit = defineEmits(['close']);
const searchContacts = createContactSearcher();
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
@@ -107,15 +108,17 @@ const onContactSearch = debounce(
isSearching.value = true;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
const results = await searchContacts(query);
// null means the request was aborted (a newer search is in-flight),
if (results === null) return;
contacts.value = results;
isSearching.value = false;
} catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
}
},
300,
400,
false
);
@@ -138,6 +141,7 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
contact = rest;
}
selectedContact.value = contact;
contacts.value = [];
if (contact?.id) {
isFetchingInboxes.value = true;
try {
@@ -273,7 +277,7 @@ useKeyboardEvents(keyboardEvents);
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
]"
class="relative"
:class="{
@@ -15,6 +15,9 @@ import {
prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue';
@@ -22,6 +25,7 @@ import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
const props = defineProps({
contacts: { type: Array, default: () => [] },
@@ -42,6 +46,7 @@ const props = defineProps({
const emit = defineEmits([
'searchContacts',
'resetContactSearch',
'discard',
'updateSelectedContact',
'updateTargetInbox',
@@ -51,6 +56,8 @@ const emit = defineEmits([
const DEFAULT_FORMATTING = 'Context::Default';
const copilot = useCopilotReply();
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
@@ -157,7 +164,7 @@ const isAnyDropdownActive = computed(() => {
});
const handleContactSearch = value => {
showContactsDropdown.value = true;
showContactsDropdown.value = value.trim().length > 1;
emit('searchContacts', value);
};
@@ -172,12 +179,16 @@ const handleDropdownUpdate = (type, value) => {
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
showBccEmailsDropdown.value = false;
emit('resetContactSearch');
showCcEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value);
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
showCcEmailsDropdown.value = false;
emit('resetContactSearch');
showBccEmailsDropdown.value = value.trim().length >= 2;
emit('searchContacts', value);
};
@@ -196,6 +207,7 @@ const stripMessageFormatting = channelType => {
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset();
copilot.reset(false);
// Strip unsupported formatting when changing the target inbox
if (channelType) {
@@ -222,6 +234,7 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => {
v$.value.$reset();
copilot.reset(false);
removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING);
@@ -231,6 +244,7 @@ const removeTargetInbox = value => {
};
const clearSelectedContact = () => {
copilot.reset(false);
removeSignatureFromMessage();
emit('clearSelectedContact');
state.message = '';
@@ -262,6 +276,7 @@ const handleAttachFile = files => {
};
const clearForm = () => {
copilot.reset(false);
Object.assign(state, {
message: '',
subject: '',
@@ -324,6 +339,24 @@ const shouldShowMessageEditor = computed(() => {
!inboxTypes.value.isTwilioWhatsapp
);
});
const isCopilotActive = computed(() => copilot.isActive?.value ?? false);
const onSubmitCopilotReply = () => {
const acceptedMessage = copilot.accept();
state.message = acceptedMessage;
};
useKeyboardEvents({
'$mod+Enter': {
action: () => {
if (isCopilotActive.value && !copilot.isButtonDisabled.value) {
onSubmitCopilotReply();
}
},
allowOnFocusedInput: true,
},
});
</script>
<template>
@@ -354,6 +387,7 @@ const shouldShowMessageEditor = computed(() => {
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
:is-fetching-inboxes="isFetchingInboxes"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
@@ -382,6 +416,7 @@ const shouldShowMessageEditor = computed(() => {
:has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
:copilot="copilot"
/>
<AttachmentPreviews
@@ -391,7 +426,15 @@ const shouldShowMessageEditor = computed(() => {
/>
</div>
<CopilotReplyBottomPanel
v-if="isCopilotActive"
:is-generating-content="copilot.isButtonDisabled.value"
class="h-[3.25rem] !px-4 !py-2"
@submit="onSubmitCopilotReply"
@cancel="copilot.reset"
/>
<ActionButtons
v-else
:attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
@@ -99,7 +99,6 @@ const inputClass = computed(() => {
type="email"
allow-create
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate"
@@ -133,7 +132,6 @@ const inputClass = computed(() => {
allow-create
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate"
@@ -1,9 +1,15 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
>
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
{{ t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span>
</div>
</template>
@@ -6,6 +6,7 @@ import { generateLabelForContactableInboxesList } from 'dashboard/components-nex
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
targetInbox: {
@@ -28,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isFetchingInboxes: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -71,7 +76,9 @@ const targetInboxLabel = computed(() => {
v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Spinner v-if="isFetchingInboxes" :size="16" />
<Button
v-else
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link"
size="sm"
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CopilotEditorSection from 'dashboard/components/widgets/conversation/CopilotEditorSection.vue';
const props = defineProps({
hasErrors: { type: Boolean, default: false },
@@ -10,6 +11,7 @@ const props = defineProps({
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
medium: { type: String, default: '' },
copilot: { type: Object, default: null },
});
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
@@ -20,29 +22,67 @@ const modelValue = defineModel({
type: String,
default: '',
});
const isCopilotActive = computed(() => props.copilot?.isActive?.value ?? false);
const executeCopilotAction = (action, data) => {
if (props.copilot) {
props.copilot.execute(action, data);
}
};
</script>
<template>
<div class="flex-1 h-full">
<Editor
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
/>
<div class="flex-1 h-full px-4 py-4">
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<div
:key="copilot ? copilot.editorTransitionKey.value : 'rich'"
class="h-full"
>
<CopilotEditorSection
v-if="isCopilotActive"
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
class="!mb-0"
@focus="() => {}"
@blur="() => {}"
@clear-selection="() => {}"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<Editor
v-else
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-0 [&>div]:py-0 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[12rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
enable-variables
enable-captain-tools
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
:medium="medium"
@execute-copilot-action="executeCopilotAction"
/>
</div>
</Transition>
</div>
</template>
@@ -177,19 +177,42 @@ export const prepareWhatsAppMessagePayload = ({
};
// API Calls
export const searchContacts = async query => {
const trimmed = typeof query === 'string' ? query.trim() : '';
if (!trimmed) return [];
const MIN_SEARCH_LENGTH = 2;
const {
data: { payload },
} = await ContactAPI.search(trimmed);
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
export const createContactSearcher = () => {
let controller = null;
return async (query, { skipMinLength = false } = {}) => {
const trimmed = typeof query === 'string' ? query.trim() : '';
controller?.abort();
if (!trimmed || (!skipMinLength && trimmed.length < MIN_SEARCH_LENGTH))
return [];
controller = new AbortController();
const { signal } = controller;
try {
const {
data: { payload },
} = await ContactAPI.search(trimmed, 1, 'name', '', { signal });
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
} catch (error) {
// Return null for aborted requests so callers can distinguish
// "request was cancelled" from "no results found"
if (error?.name === 'AbortError' || error?.name === 'CanceledError') {
return null;
}
throw error;
}
};
};
export const createNewContact = async input => {
@@ -337,7 +337,12 @@ describe('composeConversationHelper', () => {
});
describe('API calls', () => {
describe('searchContacts', () => {
describe('createContactSearcher', () => {
let searchContacts;
beforeEach(() => {
searchContacts = helpers.createContactSearcher();
});
it('searches contacts and returns camelCase results', async () => {
const mockPayload = [
{
@@ -353,7 +358,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('john');
const result = await searchContacts('john');
expect(result).toEqual([
{
@@ -365,7 +370,56 @@ describe('composeConversationHelper', () => {
},
]);
expect(ContactAPI.search).toHaveBeenCalledWith('john');
expect(ContactAPI.search).toHaveBeenCalledWith(
'john',
1,
'name',
'',
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
});
it('returns empty array for queries shorter than 2 characters', async () => {
const result = await searchContacts('j');
expect(result).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('returns empty array for empty or whitespace-only queries', async () => {
expect(await searchContacts('')).toEqual([]);
expect(await searchContacts(' ')).toEqual([]);
expect(await searchContacts(null)).toEqual([]);
expect(ContactAPI.search).not.toHaveBeenCalled();
});
it('aborts previous in-flight request when a new search starts', async () => {
const mockPayload = [
{ id: 1, name: 'Result', email: 'r@test.com', phone_number: null },
];
let resolveFirst;
const firstCall = new Promise(resolve => {
resolveFirst = resolve;
});
ContactAPI.search
.mockReturnValueOnce(firstCall)
.mockResolvedValueOnce({ data: { payload: mockPayload } });
// Start first search (will hang)
const first = searchContacts('alpha');
// Start second search (aborts first)
const second = searchContacts('beta');
// Resolve the first call with CanceledError (simulating axios abort)
const canceledError = new Error('canceled');
canceledError.name = 'CanceledError';
resolveFirst(Promise.reject(canceledError));
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toBeNull();
expect(secondResult).toEqual([
{ id: 1, name: 'Result', email: 'r@test.com', phoneNumber: null },
]);
});
it('searches contacts and returns only contacts with email or phone number', async () => {
@@ -397,7 +451,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('john');
const result = await searchContacts('john');
// Should only return contacts with either email or phone number
expect(result).toEqual([
@@ -417,7 +471,13 @@ describe('composeConversationHelper', () => {
},
]);
expect(ContactAPI.search).toHaveBeenCalledWith('john');
expect(ContactAPI.search).toHaveBeenCalledWith(
'john',
1,
'name',
'',
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
});
it('handles empty search results', async () => {
@@ -425,7 +485,7 @@ describe('composeConversationHelper', () => {
data: { payload: [] },
});
const result = await helpers.searchContacts('nonexistent');
const result = await searchContacts('nonexistent');
expect(result).toEqual([]);
});
@@ -452,7 +512,7 @@ describe('composeConversationHelper', () => {
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('test');
const result = await searchContacts('test');
expect(result).toEqual([
{
@@ -474,6 +534,36 @@ describe('composeConversationHelper', () => {
});
});
describe('createContactSearcher isolation', () => {
it('creates isolated searcher instances that do not cancel each other', async () => {
const searcherA = helpers.createContactSearcher();
const searcherB = helpers.createContactSearcher();
const payloadA = [
{ id: 1, name: 'Alice', email: 'a@test.com', phone_number: null },
];
const payloadB = [
{ id: 2, name: 'Bob', email: 'b@test.com', phone_number: null },
];
ContactAPI.search
.mockResolvedValueOnce({ data: { payload: payloadA } })
.mockResolvedValueOnce({ data: { payload: payloadB } });
const [resultA, resultB] = await Promise.all([
searcherA('alice'),
searcherB('bob'),
]);
expect(resultA).toEqual([
{ id: 1, name: 'Alice', email: 'a@test.com', phoneNumber: null },
]);
expect(resultB).toEqual([
{ id: 2, name: 'Bob', email: 'b@test.com', phoneNumber: null },
]);
});
});
describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
@@ -18,10 +18,18 @@ const newMessage = ref('');
const isLoading = ref(false);
const formatMessagesForApi = () => {
return messages.value.map(message => ({
role: message.sender,
content: message.content,
}));
return messages.value.map(message => {
const payload = {
role: message.sender,
content: message.content,
};
if (message.sender === 'assistant' && message.agentName) {
payload.agent_name = message.agentName;
}
return payload;
});
};
const resetConversation = () => {
@@ -62,6 +70,7 @@ const sendMessage = async () => {
messages.value.push({
content: data.response,
sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(),
});
} catch (error) {
@@ -71,6 +80,12 @@ const sendMessage = async () => {
isLoading.value = false;
}
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
</script>
<template>
@@ -104,7 +119,7 @@ const sendMessage = async () => {
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<NextButton
ghost
@@ -29,6 +29,12 @@ const handleInput = () => {
nextTick(adjustHeight);
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
onMounted(() => {
nextTick(adjustHeight);
});
@@ -43,7 +49,7 @@ onMounted(() => {
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -96,6 +96,17 @@ const close = () => {
isOpen.value = false;
};
// Only close if the close event originated from this dialog,
// not from a child dialog (e.g. ProseMirror prompt) bubbling up.
const handleDialogClose = e => e.target === dialogRef.value && close();
// Only close on click-outside if this dialog is the topmost one.
// If another dialog (e.g. ProseMirror prompt) is open on top, ignore.
const handleClickOutside = () => {
const dialogs = document.querySelectorAll('dialog[open]');
if (dialogs[dialogs.length - 1] === dialogRef.value) close();
};
const confirm = () => {
emit('confirm');
};
@@ -113,9 +124,9 @@ defineExpose({ open, close });
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
@close="close"
@close.prevent="handleDialogClose"
>
<OnClickOutside @trigger="close">
<OnClickOutside @trigger="handleClickOutside">
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@@ -67,6 +67,9 @@ const isSent = computed(() => {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
// API inbox messages use real sent/delivered/read status values from the external system.
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.SENT;
// All messages will be mark as sent for the Line channel, as there is no source ID.
if (isALineChannel.value) return true;
@@ -81,12 +84,15 @@ const isDelivered = computed(() => {
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAWebWidgetInbox.value || isAPIInbox.value) {
// API inbox messages use real delivered status from the external system.
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.DELIVERED;
// All messages marked as delivered for the web widget inbox once they are sent.
if (isAWebWidgetInbox.value) {
return status.value === MESSAGE_STATUS.SENT;
}
if (isALineChannel.value) {
@@ -72,7 +72,7 @@ const isNewTagInValidType = computed(() =>
const showInput = computed(() =>
props.mode === MODE.SINGLE
? isFocused.value && !tags.value.length
? !tags.value.length
: isFocused.value || !tags.value.length
);
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
@@ -10,11 +10,23 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
const props = defineProps({
hasSelection: {
type: Boolean,
default: false,
},
isEditorMenuPopover: {
type: Boolean,
default: false,
},
editorContent: {
type: String,
default: undefined,
},
conversationId: {
type: Number,
default: null,
},
});
const emit = defineEmits(['executeCopilotAction']);
@@ -25,6 +37,13 @@ const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// When editorContent prop is passed, use it exclusively (even if empty)
// This ensures each editor instance shows menu items based on its own content
// Falls back to global draftMessage only when editorContent is not provided
const effectiveContent = computed(() =>
props.editorContent !== undefined ? props.editorContent : draftMessage.value
);
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
@@ -42,8 +61,9 @@ const menuItems = computed(() => {
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
props.conversationId &&
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
effectiveContent.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
@@ -52,7 +72,7 @@ const menuItems = computed(() => {
});
}
if (draftMessage.value) {
if (effectiveContent.value) {
items.push(
{
label: t(
@@ -105,7 +125,7 @@ const menuItems = computed(() => {
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
if (props.conversationId && replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
@@ -113,7 +133,10 @@ const generalMenuItems = computed(() => {
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
if (
props.conversationId &&
(replyMode.value === REPLY_EDITOR_MODES.NOTE || true)
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
@@ -176,8 +199,8 @@ const handleSubMenuItemClick = (parentItem, subItem) => {
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
:class="{ 'selection-menu': hasSelection && isEditorMenuPopover }"
:style="hasSelection && isEditorMenuPopover ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
@@ -202,6 +202,11 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const isEditorMenuPopover = computed(
() =>
editorRoot.value?.classList.contains('popover-prosemirror-menu') ?? false
);
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
@@ -211,7 +216,7 @@ const handleCopilotAction = actionKey => {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
emit('executeCopilotAction', actionKey, props.modelValue);
}
showSelectionMenu.value = false;
@@ -484,6 +489,7 @@ function setToolbarPosition() {
function setMenubarPosition({ selection } = {}) {
const wrapper = editorRoot.value;
if (!selection || !wrapper) return;
if (!isEditorMenuPopover.value) return;
const rect = wrapper.getBoundingClientRect();
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
@@ -866,8 +872,12 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:is-editor-menu-popover="isEditorMenuPopover"
:editor-content="modelValue"
:conversation-id="conversationId"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
class="copilot-editor-menu"
@execute-copilot-action="handleCopilotAction"
/>
<input
@@ -968,11 +978,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
}
.ProseMirror-prompt-backdrop::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
@apply bg-n-alpha-3 border border-n-strong p-6 shadow-xl rounded-xl w-96 !important;
h5 {
@apply text-n-slate-12 mb-1.5;
@apply text-n-slate-12 mb-3;
}
.ProseMirror-prompt-buttons {
@@ -1026,6 +1040,17 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
}
// Default copilot menu position (non-popover editors like components-next/Editor)
// When popover-prosemirror-menu is NOT on the wrapper, anchor below the menubar
:not(.popover-prosemirror-menu) > .copilot-editor-menu {
top: 1.5rem !important;
[dir='rtl'] & {
left: auto !important;
right: 0 !important;
}
}
// Float editor menu
.popover-prosemirror-menu {
position: relative;
@@ -326,26 +326,4 @@ export default {
max-height: 7.5rem;
overflow: auto;
}
.ProseMirror-prompt {
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
h5 {
@apply text-n-slate-12 mb-1.5;
}
.ProseMirror-prompt-buttons {
button {
@apply h-8 px-3;
&[type='submit'] {
@apply bg-n-brand text-white hover:bg-n-brand/90;
}
&[type='button'] {
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
}
}
}
}
</style>
@@ -49,6 +49,10 @@ export default {
type: Number,
default: () => 0,
},
editorContent: {
type: String,
default: undefined,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
@@ -73,8 +77,8 @@ export default {
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
const handleCopilotAction = (actionKey, data) => {
emit('executeCopilotAction', actionKey, data || props.editorContent);
showCopilotMenu.value = false;
};
@@ -174,6 +178,8 @@ export default {
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
:editor-content="editorContent"
:conversation-id="conversationId"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
@@ -78,7 +78,7 @@ const onSend = () => {
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
@@ -162,7 +162,8 @@ export default {
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
!this.is360DialogWhatsAppChannel
!this.is360DialogWhatsAppChannel &&
!this.copilot.isActive.value
);
},
showWhatsappTemplates() {
@@ -1245,6 +1246,7 @@ export default {
:is-editor-disabled="isEditorDisabled"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:editor-content="message"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@@ -1463,7 +1465,7 @@ export default {
}
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
@apply relative py-0 px-3 -mt-px;
}
.emoji-dialog {
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
<template>
<div
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">
@@ -100,6 +100,8 @@ export default {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'agents/getVerifiedAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,
@@ -111,7 +111,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
'agents/getVerifiedAgents': { value: mockAgents },
});
});
@@ -167,7 +167,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
'agents/getVerifiedAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
@@ -20,7 +20,7 @@ import {
export default function useAutomationValues() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const agents = useMapGetter('agents/getVerifiedAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
@@ -13,7 +13,7 @@ export const useMacros = () => {
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
/**
* Get dropdown values based on the specified type
@@ -119,6 +119,10 @@ export const COPILOT_EVENTS = Object.freeze({
USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response',
});
export const SNOOZE_EVENTS = Object.freeze({
NLP_SNOOZE_APPLIED: 'Applied snooze via text-to-date input',
});
export const GENERAL_EVENTS = Object.freeze({
COMMAND_BAR: 'Used commandbar',
});
@@ -0,0 +1,12 @@
/**
* snoozeDateParser Natural language date/time parser for snooze.
*
* Barrel re-export from submodules:
* - parser.js: core parsing engine (parseDateFromText)
* - localization.js: multilingual suggestion generator (generateDateSuggestions)
* - suggestions.js: compositional suggestion engine
* - tokenMaps.js: shared token maps and utility functions
*/
export { parseDateFromText } from './parser';
export { generateDateSuggestions } from './localization';
@@ -0,0 +1,408 @@
/**
* Handles non-English input and generates the final suggestion list.
* Translates localized words to English before parsing, then converts
* suggestion labels back to the user's language for display.
*/
import {
WEEKDAY_MAP,
MONTH_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
RELATIVE_DAY_MAP,
TIME_OF_DAY_MAP,
sanitize,
stripNoise,
normalizeDigits,
} from './tokenMaps';
import { parseDateFromText } from './parser';
import { buildSuggestionCandidates, MAX_SUGGESTIONS } from './suggestions';
// ─── English Reference Data ─────────────────────────────────────────────────
const EN_WEEKDAYS_LIST = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
];
const EN_MONTHS_LIST = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
const EN_DEFAULTS = {
UNITS: {
MINUTE: 'minute',
MINUTES: 'minutes',
HOUR: 'hour',
HOURS: 'hours',
DAY: 'day',
DAYS: 'days',
WEEK: 'week',
WEEKS: 'weeks',
MONTH: 'month',
MONTHS: 'months',
YEAR: 'year',
YEARS: 'years',
},
RELATIVE: {
TOMORROW: 'tomorrow',
DAY_AFTER_TOMORROW: 'day after tomorrow',
NEXT_WEEK: 'next week',
NEXT_MONTH: 'next month',
THIS_WEEKEND: 'this weekend',
NEXT_WEEKEND: 'next weekend',
},
TIME_OF_DAY: {
MORNING: 'morning',
AFTERNOON: 'afternoon',
EVENING: 'evening',
NIGHT: 'night',
NOON: 'noon',
MIDNIGHT: 'midnight',
},
WORD_NUMBERS: {
ONE: 'one',
TWO: 'two',
THREE: 'three',
FOUR: 'four',
FIVE: 'five',
SIX: 'six',
SEVEN: 'seven',
EIGHT: 'eight',
NINE: 'nine',
TEN: 'ten',
TWELVE: 'twelve',
FIFTEEN: 'fifteen',
TWENTY: 'twenty',
THIRTY: 'thirty',
},
ORDINALS: {
FIRST: 'first',
SECOND: 'second',
THIRD: 'third',
FOURTH: 'fourth',
FIFTH: 'fifth',
},
MERIDIEM: { AM: 'am', PM: 'pm' },
HALF: 'half',
NEXT: 'next',
THIS: 'this',
AT: 'at',
IN: 'in',
OF: 'of',
AFTER: 'after',
WEEK: 'week',
DAY: 'day',
FROM_NOW: 'from now',
NEXT_YEAR: 'next year',
};
const STRUCTURAL_WORDS = [
'at',
'in',
'next',
'this',
'from',
'now',
'after',
'half',
'same',
'time',
'weekend',
'end',
'of',
'the',
'eod',
'am',
'pm',
'week',
'day',
'first',
'second',
'third',
'fourth',
'fifth',
];
const ENGLISH_VOCAB = new Set([
...Object.keys(WEEKDAY_MAP),
...Object.keys(MONTH_MAP),
...Object.keys(UNIT_MAP),
...Object.keys(WORD_NUMBER_MAP),
...Object.keys(RELATIVE_DAY_MAP),
...Object.keys(TIME_OF_DAY_MAP),
...EN_WEEKDAYS_LIST,
...EN_MONTHS_LIST,
...STRUCTURAL_WORDS,
]);
// ─── Regex for token replacement ────────────────────────────────────────────
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const MONTH_NAME_RE = new RegExp(`\\b(?:${MONTH_NAMES})\\b`, 'i');
const NUM_TOD_RE =
/\b(\d{1,2}(?::\d{2})?)\s+(morning|noon|afternoon|evening|night)\b/g;
const TOD_TO_MERIDIEM = {
morning: 'am',
noon: 'pm',
afternoon: 'pm',
evening: 'pm',
night: 'pm',
};
// ─── Translation Cache ──────────────────────────────────────────────────────
const safeString = v => (v == null ? '' : String(v));
const MAX_PAIRS_CACHE = 20;
const pairsCache = new Map();
const CACHE_SECTIONS = [
'UNITS',
'RELATIVE',
'TIME_OF_DAY',
'WORD_NUMBERS',
'ORDINALS',
'MERIDIEM',
];
const SINGLE_KEYS = [
'HALF',
'NEXT',
'THIS',
'AT',
'IN',
'OF',
'AFTER',
'WEEK',
'DAY',
'FROM_NOW',
'NEXT_YEAR',
];
/** Create a string key from translations so we can cache results. */
const translationSignature = translations => {
if (!translations || typeof translations !== 'object') return 'none';
return [
...CACHE_SECTIONS.flatMap(section => {
const values = translations[section] || {};
return Object.keys(values)
.sort()
.map(k => `${section}.${k}:${safeString(values[k]).toLowerCase()}`);
}),
...SINGLE_KEYS.map(
k => `${k}:${safeString(translations[k]).toLowerCase()}`
),
].join('|');
};
/** Build a list of [localWord, englishWord] pairs from the translations and browser locale. */
const buildReplacementPairsUncached = (translations, locale) => {
const pairs = [];
const seen = new Set();
const t = translations || {};
const addPair = (local, en) => {
const l = sanitize(safeString(local));
const e = safeString(en).toLowerCase();
const key = `${l}\0${e}`;
if (l && e && l !== e && !seen.has(key)) {
seen.add(key);
pairs.push([l, e]);
}
};
CACHE_SECTIONS.forEach(section => {
const localSection = t[section] || {};
const enSection = EN_DEFAULTS[section] || {};
Object.keys(enSection).forEach(key => {
addPair(localSection[key], enSection[key]);
});
});
SINGLE_KEYS.forEach(key => addPair(t[key], EN_DEFAULTS[key]));
try {
const wdFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
// Jan 1, 2024 is a Monday — aligns with EN_WEEKDAYS_LIST[0]='monday'
EN_WEEKDAYS_LIST.forEach((en, i) => {
addPair(wdFmt.format(new Date(2024, 0, i + 1)), en);
});
} catch {
/* locale not supported */
}
try {
const moFmt = new Intl.DateTimeFormat(locale, { month: 'long' });
EN_MONTHS_LIST.forEach((en, i) => {
addPair(moFmt.format(new Date(2024, i, 1)), en);
});
} catch {
/* locale not supported */
}
pairs.sort((a, b) => b[0].length - a[0].length);
return pairs;
};
/** Same as above but cached. Keeps up to 20 entries to avoid rebuilding every call. */
const buildReplacementPairs = (translations, locale) => {
const cacheKey = `${locale || ''}:${translationSignature(translations)}`;
if (pairsCache.has(cacheKey)) return pairsCache.get(cacheKey);
const pairs = buildReplacementPairsUncached(translations, locale);
if (pairsCache.size >= MAX_PAIRS_CACHE)
pairsCache.delete(pairsCache.keys().next().value);
pairsCache.set(cacheKey, pairs);
return pairs;
};
// ─── Token Replacement ──────────────────────────────────────────────────────
const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** Swap localized words for their English versions in the text. */
const substituteLocalTokens = (text, pairs) => {
let r = text;
pairs.forEach(([local, en]) => {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
});
return r;
};
/** Drop any words the parser wouldn't understand (keeps English words and numbers). */
const filterToEnglishVocab = text =>
normalizeDigits(text)
.replace(/(\d+)h\b/g, '$1:00')
.split(/\s+/)
.filter(w => /[\d:]/.test(w) || ENGLISH_VOCAB.has(w.toLowerCase()))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
/** Move "next year" to the right spot so the parser can read it (after the month, before time). */
const repositionNextYear = text => {
if (!MONTH_NAME_RE.test(text)) return text;
let r = text.replace(/\b(?:next\s+)?year\b/i, m =>
/next/i.test(m) ? m : 'next year'
);
if (!/\bnext\s+year\b/i.test(r)) return r;
const withoutNY = r.replace(/\bnext\s+year\b/i, '').trim();
const timeRe = /(?:(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\s*$/i;
const timePart = withoutNY.match(timeRe);
if (timePart) {
const beforeTime = withoutNY.slice(0, timePart.index).trim();
r = `${beforeTime} next year ${timePart[0].trim()}`;
} else {
r = `${withoutNY} next year`;
}
return r;
};
/** Run the full translation pipeline: swap tokens, filter, fix am/pm, reposition "next year". */
const replaceTokens = (text, pairs) => {
const substituted = substituteLocalTokens(text, pairs);
const filtered = filterToEnglishVocab(substituted);
const fixed = filtered.replace(
NUM_TOD_RE,
(_, t, tod) => `${t}${TOD_TO_MERIDIEM[tod]}`
);
return stripNoise(repositionNextYear(fixed));
};
/** Convert English words back to the user's language for display. */
const reverseTokens = (text, pairs) =>
pairs.reduce(
(r, [local, en]) =>
r.replace(
new RegExp(`(?<=^|\\s)${escapeRegex(en)}(?=\\s|$)`, 'g'),
local
),
text
);
// ─── Main Suggestion Generator ──────────────────────────────────────────────
/**
* Generate snooze suggestions from what the user has typed so far.
* Works with any language if translations are provided. Returns up to 5
* unique results, each with a label, date, and unix timestamp.
*
* @param {string} text - what the user typed
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @param {{ translations?: object, locale?: string }} [options] - i18n config
* @returns {Array<{ label: string, date: Date, unix: number }>}
*/
export const generateDateSuggestions = (
text,
referenceDate = new Date(),
{ translations, locale } = {}
) => {
if (!text || typeof text !== 'string') return [];
const normalized = sanitize(text);
if (!normalized) return [];
const stripped = stripNoise(normalized);
const pairs =
locale && locale !== 'en'
? buildReplacementPairs(translations, locale)
: [];
// Try English parse first, then translated parse if we have locale pairs.
// This avoids the problem where a single overlapping word (e.g. "in" in German)
// would skip token translation entirely.
const directParse = parseDateFromText(stripped, referenceDate);
const translated = pairs.length ? replaceTokens(normalized, pairs) : null;
const translatedParse =
translated && translated !== stripped
? parseDateFromText(translated, referenceDate)
: null;
// Prefer direct English parse; fall back to translated parse
const useTranslated = !directParse && !!translatedParse;
const englishInput = useTranslated ? translated : stripped;
const seen = new Set();
const results = [];
const exact = directParse || translatedParse;
if (exact) {
seen.add(exact.unix);
const exactLabel =
useTranslated && pairs.length
? reverseTokens(englishInput, pairs)
: englishInput;
results.push({ label: exactLabel, query: englishInput, ...exact });
}
buildSuggestionCandidates(englishInput).some(candidate => {
if (results.length >= MAX_SUGGESTIONS) return true;
const result = parseDateFromText(candidate, referenceDate);
if (result && !seen.has(result.unix)) {
seen.add(result.unix);
const label =
useTranslated && pairs.length
? reverseTokens(candidate, pairs)
: candidate;
results.push({ label, query: candidate, ...result });
}
return false;
});
return results;
};
@@ -0,0 +1,806 @@
/**
* Parses natural language text into a future date.
*
* Flow: clean the input try each matcher in order return the first future date.
* The MATCHERS order matters see the comment above the array.
*/
import {
add,
startOfDay,
getDay,
isSaturday,
isSunday,
nextFriday,
nextSaturday,
getUnixTime,
isValid,
startOfWeek,
addWeeks,
isAfter,
isBefore,
endOfMonth,
} from 'date-fns';
import {
WEEKDAY_MAP,
MONTH_MAP,
RELATIVE_DAY_MAP,
UNIT_MAP,
WORD_NUMBER_MAP,
NEXT_WEEKDAY_FN,
TIME_OF_DAY_MAP,
TOD_HOUR_RANGE,
HALF_UNIT_DURATIONS,
sanitize,
stripNoise,
parseNumber,
parseTimeString,
applyTimeToDate,
applyTimeOrDefault,
strictDate,
futureOrNextYear,
ensureFutureOrNextDay,
inferHoursFromTOD,
addFractionalSafe,
} from './tokenMaps';
// ─── Regex Fragments (derived from maps) ────────────────────────────────────
const WEEKDAY_NAMES = Object.keys(WEEKDAY_MAP).join('|');
const MONTH_NAMES = Object.keys(MONTH_MAP).join('|');
const UNIT_NAMES = Object.keys(UNIT_MAP).join('|');
const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|');
const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|');
const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight';
const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`;
const UNIT_RE = `(${UNIT_NAMES})`;
const TIME_SUFFIX_RE =
'(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?';
const ORDINAL_MAP = {
first: 1,
second: 2,
third: 3,
fourth: 4,
fifth: 5,
sixth: 6,
seventh: 7,
eighth: 8,
ninth: 9,
tenth: 10,
};
const parseOrdinal = str => {
if (ORDINAL_MAP[str]) return ORDINAL_MAP[str];
return parseInt(str.replace(/(?:st|nd|rd|th)$/, ''), 10) || null;
};
const ORDINAL_WORDS = Object.keys(ORDINAL_MAP).join('|');
const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
// ─── Pre-compiled Regexes ───────────────────────────────────────────────────
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
const DURATION_FROM_NOW_RE = new RegExp(
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
);
const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
const RELATIVE_DAY_TOD_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
);
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const RELATIVE_DAY_AT_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?` +
'(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2})$'
);
const RELATIVE_DAY_SAME_TIME_RE = new RegExp(
`^(?:(${RELATIVE_DAYS})\\s+(?:same\\s+time|this\\s+time)|(?:same\\s+time|this\\s+time)\\s+(${RELATIVE_DAYS}))$`
);
const NEXT_UNIT_RE = new RegExp(
`^next\\s+(hour|minute|week|month|year)${TIME_SUFFIX_RE}$`
);
const NEXT_MONTH_RE = new RegExp(`^next\\s+(${MONTH_NAMES})${TIME_SUFFIX_RE}$`);
const NEXT_WEEKDAY_TOD_RE = new RegExp(
`^next\\s+(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const NEXT_WEEKDAY_RE = new RegExp(
`^(?:(${WEEKDAY_NAMES})\\s+(?:of\\s+)?next\\s+week` +
`|next\\s+week\\s+(${WEEKDAY_NAMES})` +
`|next\\s+(${WEEKDAY_NAMES}))${TIME_SUFFIX_RE}$`
);
const SAME_TIME_WEEKDAY_RE = new RegExp(
`^(?:same\\s+time|this\\s+time)\\s+(${WEEKDAY_NAMES})$`
);
const WEEKDAY_TOD_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})$`
);
const WEEKDAY_TOD_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?` +
`(${WEEKDAY_NAMES})\\s+(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
const WEEKDAY_TIME_RE = new RegExp(
`^(?:(?:this|upcoming|coming)\\s+)?(${WEEKDAY_NAMES})${TIME_SUFFIX_RE}$`
);
const TIME_ONLY_MERIDIEM_RE =
/^(?:at\s+)?(\d{1,2}(?::\d{2})?\s*(?:am|pm|a\.m\.?|p\.m\.?))$/;
const TIME_ONLY_24H_RE = /^(?:at\s+)?(\d{1,2}:\d{2})$/;
const TOD_WITH_TIME_RE = new RegExp(
`^(?:(?:this|the)\\s+)?(${TIME_OF_DAY_NAMES})\\s+` +
'(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*' +
'(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const TOD_PLAIN_RE = new RegExp(
'(?:(?:later|in)\\s+)?(?:(?:this|the)\\s+)?' +
`(?:${TIME_OF_DAY_NAMES}|eod|end of day|end of the day)$`
);
const ABSOLUTE_DATE_RE = new RegExp(
`^(${MONTH_NAMES})\\s+(\\d{1,2})(?:st|nd|rd|th)?` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const ABSOLUTE_DATE_REVERSED_RE = new RegExp(
`^(\\d{1,2})(?:st|nd|rd|th)?\\s+(${MONTH_NAMES})` +
`(?:[,\\s]+(\\d{4}|next\\s+year))?${TIME_SUFFIX_RE}$`
);
const MONTH_YEAR_RE = new RegExp(`^(${MONTH_NAMES})\\s+(\\d{4})$`);
// "april first week", "first week of april", "march 2nd day", "5th day of jan"
const MONTH_ORDINAL_RE = new RegExp(
`^(?:(${MONTH_NAMES})\\s+${ORDINAL_RE}\\s+(week|day)|${ORDINAL_RE}\\s+(week|day)\\s+of\\s+(${MONTH_NAMES}))${TIME_SUFFIX_RE}$`
);
const DAY_AFTER_TOMORROW_RE = new RegExp(
`^day\\s+after\\s+tomorrow${TIME_SUFFIX_RE}$`
);
const COMPOUND_DURATION_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+(?:and\\s+)?${NUM_RE}\\s+${UNIT_RE}$`
);
const DURATION_AT_TIME_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+at\\s+` +
'(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?)$'
);
const END_OF_RE = /^end\s+of\s+(?:the\s+)?(week|month|day)$/;
const END_OF_NEXT_RE = /^end\s+of\s+(?:the\s+)?next\s+(week|month)$/;
const START_OF_NEXT_RE =
/^(?:beginning|start)\s+of\s+(?:the\s+)?next\s+(week|month)$/;
const LATER_TODAY_RE = /^later\s+(?:today|this\s+(?:afternoon|evening))$/;
const EARLY_LATE_TOD_RE = new RegExp(
`^(early|late)\\s+(${TIME_OF_DAY_NAMES})$`
);
const ONE_AND_HALF_RE = new RegExp(
`^(?:in\\s+)?(?:one\\s+and\\s+(?:a\\s+)?half|an?\\s+hour\\s+and\\s+(?:a\\s+)?half)(?:\\s+${UNIT_RE})?$`
);
const NEXT_BUSINESS_DAY_RE = /^next\s+(?:business|working)\s+day$/;
const TIME_SUFFIX_COMPILED = new RegExp(`${TIME_SUFFIX_RE}$`);
const ISO_DATE_RE = new RegExp(
`^(\\d{4})-(\\d{1,2})-(\\d{1,2})${TIME_SUFFIX_COMPILED.source}`
);
const SLASH_DATE_RE = new RegExp(
`^(\\d{1,2})/(\\d{1,2})/(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DASH_DATE_RE = new RegExp(
`^(\\d{1,2})-(\\d{1,2})-(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
const DOT_DATE_RE = new RegExp(
`^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})${TIME_SUFFIX_COMPILED.source}`
);
// ─── Pattern Matchers ───────────────────────────────────────────────────────
/** Read amount and unit from a regex match, then add to now. */
const parseDuration = (match, now) => {
if (!match) return null;
const amount = parseNumber(match[1]);
const unit = UNIT_MAP[match[2]];
if (amount == null || !unit) return null;
return addFractionalSafe(now, unit, amount);
};
/** Handle "in 2 hours", "half day", "3h30m", "5 min from now". */
const matchDuration = (text, now) => {
const half = text.match(HALF_UNIT_RE);
if (half) {
return HALF_UNIT_DURATIONS[half[1]]
? add(now, HALF_UNIT_DURATIONS[half[1]])
: null;
}
// "one and a half hours", "an hour and a half"
const oneHalf = text.match(ONE_AND_HALF_RE);
if (oneHalf) {
const unit = UNIT_MAP[oneHalf[1]] || 'hours';
return addFractionalSafe(now, unit, 1.5);
}
const compound = text.match(COMPOUND_DURATION_RE);
if (compound) {
const a1 = parseNumber(compound[1]);
const u1 = UNIT_MAP[compound[2]];
const a2 = parseNumber(compound[3]);
const u2 = UNIT_MAP[compound[4]];
if (a1 == null || !u1 || a2 == null || !u2) {
return null;
}
return add(add(now, { [u1]: a1 }), { [u2]: a2 });
}
const atTime = text.match(DURATION_AT_TIME_RE);
if (atTime) {
const amount = parseNumber(atTime[1]);
const unit = UNIT_MAP[atTime[2]];
const time = parseTimeString(atTime[3]);
if (amount == null || !unit || !time) {
return null;
}
return applyTimeToDate(
add(now, { [unit]: amount }),
time.hours,
time.minutes
);
}
return (
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
);
};
/** Set time on a day offset. If the result is already past, move to the next day. */
const applyTimeWithRollover = (offset, hours, minutes, now) => {
const base = add(startOfDay(now), { days: offset });
const date = applyTimeToDate(base, hours, minutes);
if (isAfter(date, now)) return date;
return applyTimeToDate(add(base, { days: 1 }), hours, minutes);
};
/** Handle "today", "tonight", "tomorrow" with optional time. */
const matchRelativeDay = (text, now) => {
const dayOnlyMatch = text.match(RELATIVE_DAY_ONLY_RE);
if (dayOnlyMatch) {
const key = dayOnlyMatch[1];
const offset = RELATIVE_DAY_MAP[key];
if (key === 'tonight' || key === 'tonite') {
return ensureFutureOrNextDay(
applyTimeToDate(add(startOfDay(now), { days: offset }), 20, 0),
now
);
}
if (offset === 1) {
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 9, 0);
}
return add(now, { hours: 1 });
}
const dayTodTimeMatch = text.match(RELATIVE_DAY_TOD_TIME_RE);
if (dayTodTimeMatch) {
const timeParts = dayTodTimeMatch[3].split(':');
const time = inferHoursFromTOD(
dayTodTimeMatch[2],
timeParts[0],
timeParts[1]
);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodTimeMatch[1]],
time.hours,
time.minutes,
now
);
}
const dayTodMatch = text.match(RELATIVE_DAY_TOD_RE);
if (dayTodMatch) {
const { hours, minutes } = TIME_OF_DAY_MAP[dayTodMatch[2]];
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayTodMatch[1]],
hours,
minutes,
now
);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch;
const bare = /^(tonight|tonite)$/.test(dayKey) && !/[ap]m/i.test(timeRaw);
const time = bare
? inferHoursFromTOD('tonight', ...timeRaw.split(':'))
: parseTimeString(timeRaw);
if (!time) return null;
return applyTimeWithRollover(
RELATIVE_DAY_MAP[dayKey],
time.hours,
time.minutes,
now
);
}
const sameTimeMatch = text.match(RELATIVE_DAY_SAME_TIME_RE);
if (sameTimeMatch) {
const offset = RELATIVE_DAY_MAP[sameTimeMatch[1] || sameTimeMatch[2]];
if (offset <= 0) return null;
return applyTimeToDate(
add(startOfDay(now), { days: offset }),
now.getHours(),
now.getMinutes()
);
}
return null;
};
/** Find the given weekday in next week (not this week). */
const nextWeekdayInNextWeek = (dayIndex, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const date = fn(now);
const sameWeek =
startOfWeek(now, { weekStartsOn: 1 }).getTime() ===
startOfWeek(date, { weekStartsOn: 1 }).getTime();
return sameWeek ? fn(date) : date;
};
/** Handle "next friday", "next week", "next month", "next january", etc. */
const matchNextPattern = (text, now) => {
const nextUnitMatch = text.match(NEXT_UNIT_RE);
if (nextUnitMatch) {
const unit = nextUnitMatch[1];
if (unit === 'hour') return add(now, { hours: 1 });
if (unit === 'minute') return add(now, { minutes: 1 });
if (unit === 'week') {
const base = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const base = add(startOfDay(now), { [`${unit}s`]: 1 });
return applyTimeOrDefault(base, nextUnitMatch[2]);
}
const nextMonthMatch = text.match(NEXT_MONTH_RE);
if (nextMonthMatch) {
const monthIdx = MONTH_MAP[nextMonthMatch[1]];
let year = now.getFullYear();
if (monthIdx <= now.getMonth()) year += 1;
const base = new Date(year, monthIdx, 1);
return applyTimeOrDefault(base, nextMonthMatch[2]);
}
// "next monday morning", "next friday midnight" — weekday + time-of-day
const nextTodMatch = text.match(NEXT_WEEKDAY_TOD_RE);
if (nextTodMatch) {
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[nextTodMatch[1]], now);
if (!date) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[nextTodMatch[2]];
return applyTimeToDate(date, hours, minutes);
}
// "monday of next week", "next week monday", "next friday" — all with optional time
const weekdayMatch = text.match(NEXT_WEEKDAY_RE);
if (weekdayMatch) {
const dayName = weekdayMatch[1] || weekdayMatch[2] || weekdayMatch[3];
const date = nextWeekdayInNextWeek(WEEKDAY_MAP[dayName], now);
if (!date) return null;
return applyTimeOrDefault(date, weekdayMatch[4]);
}
return null;
};
/** Find the next occurrence of a weekday, with optional time. */
const resolveWeekdayDate = (dayIndex, timeStr, now) => {
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
let adjusted = timeStr;
if (timeStr && /^\d{1,2}$/.test(timeStr.trim())) {
const h = parseInt(timeStr, 10);
if (h >= 1 && h <= 7) adjusted = `${h}pm`;
}
if (getDay(now) === dayIndex) {
const todayDate = applyTimeOrDefault(now, adjusted);
if (todayDate && isAfter(todayDate, now)) return todayDate;
}
return applyTimeOrDefault(fn(now), adjusted);
};
/** Handle "friday", "monday 3pm", "wed morning", "same time friday". */
const matchWeekday = (text, now) => {
const sameTimeWeekday = text.match(SAME_TIME_WEEKDAY_RE);
if (sameTimeWeekday) {
const dayIndex = WEEKDAY_MAP[sameTimeWeekday[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const target = fn(now);
return applyTimeToDate(target, now.getHours(), now.getMinutes());
}
// "monday morning 6", "friday evening 7" — weekday + tod + bare number
const todTimeMatch = text.match(WEEKDAY_TOD_TIME_RE);
if (todTimeMatch) {
const dayIndex = WEEKDAY_MAP[todTimeMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const timeParts = todTimeMatch[3].split(':');
const time = inferHoursFromTOD(todTimeMatch[2], timeParts[0], timeParts[1]);
if (!time) return null;
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, time.hours, time.minutes);
return isAfter(date, now)
? date
: applyTimeToDate(fn(now), time.hours, time.minutes);
}
// "monday morning", "friday midnight", "wednesday evening", etc.
const todMatch = text.match(WEEKDAY_TOD_RE);
if (todMatch) {
const dayIndex = WEEKDAY_MAP[todMatch[1]];
const fn = NEXT_WEEKDAY_FN[dayIndex];
if (!fn) return null;
const { hours, minutes } = TIME_OF_DAY_MAP[todMatch[2]];
const target =
getDay(now) === dayIndex ? startOfDay(now) : startOfDay(fn(now));
const date = applyTimeToDate(target, hours, minutes);
return isAfter(date, now) ? date : applyTimeToDate(fn(now), hours, minutes);
}
const match = text.match(WEEKDAY_TIME_RE);
if (!match) return null;
return resolveWeekdayDate(WEEKDAY_MAP[match[1]], match[2], now);
};
/** Handle a standalone time like "3pm", "14:30", "at 9am". */
const matchTimeOnly = (text, now) => {
const match =
text.match(TIME_ONLY_MERIDIEM_RE) || text.match(TIME_ONLY_24H_RE);
if (!match) return null;
const time = parseTimeString(match[1]);
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
};
/** Handle "morning", "evening 6pm", "eod", "this afternoon". */
const matchTimeOfDay = (text, now) => {
const todWithTime = text.match(TOD_WITH_TIME_RE);
if (todWithTime) {
const rawTime = todWithTime[2].trim();
const hasMeridiem = /(?:am|pm|a\.m|p\.m)/i.test(rawTime);
let time;
if (hasMeridiem) {
time = parseTimeString(rawTime);
const range = TOD_HOUR_RANGE[todWithTime[1]];
if (!time) return null;
if (range) {
const h = time.hours === 0 ? 24 : time.hours;
if (h < range[0] || h >= range[1]) return null;
}
} else {
const parts = rawTime.split(':');
time = inferHoursFromTOD(todWithTime[1], parts[0], parts[1]);
}
if (!time) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, time.hours, time.minutes),
now
);
}
// "early morning" → 7am, "late evening" → 21:00, "late night" → 23:00
const earlyLate = text.match(EARLY_LATE_TOD_RE);
if (earlyLate) {
const tod = TIME_OF_DAY_MAP[earlyLate[2]];
if (!tod) return null;
const shift = earlyLate[1] === 'early' ? -1 : 2;
return ensureFutureOrNextDay(
applyTimeToDate(now, tod.hours + shift, 0),
now
);
}
const match = text.match(TOD_PLAIN_RE);
if (!match) return null;
const key = text
.replace(/^(?:later|in)\s+/, '')
.replace(/^(?:this|the)\s+/, '')
.trim();
const tod = TIME_OF_DAY_MAP[key];
if (!tod) return null;
return ensureFutureOrNextDay(
applyTimeToDate(now, tod.hours, tod.minutes),
now
);
};
/** Turn month + day + optional year into a future date. */
const resolveAbsoluteDate = (month, day, yearStr, timeStr, now) => {
let year = now.getFullYear();
if (yearStr && /next\s+year/i.test(yearStr)) {
year += 1;
} else if (yearStr) {
year = parseInt(yearStr, 10);
}
if (yearStr) {
const base = strictDate(year, month, day);
if (!base) return null;
const date = applyTimeOrDefault(base, timeStr);
return date && isAfter(date, now) ? date : null;
}
return futureOrNextYear(year, month, day, timeStr, now);
};
/** Handle "jan 15", "15 march", "december 2025". */
const matchNamedDate = (text, now) => {
const abs = text.match(ABSOLUTE_DATE_RE);
if (abs) {
return resolveAbsoluteDate(
MONTH_MAP[abs[1]],
parseInt(abs[2], 10),
abs[3],
abs[4],
now
);
}
const rev = text.match(ABSOLUTE_DATE_REVERSED_RE);
if (rev) {
return resolveAbsoluteDate(
MONTH_MAP[rev[2]],
parseInt(rev[1], 10),
rev[3],
rev[4],
now
);
}
const my = text.match(MONTH_YEAR_RE);
if (my) {
const date = new Date(parseInt(my[2], 10), MONTH_MAP[my[1]], 1);
if (!isValid(date)) return null;
const result = applyTimeToDate(date, 9, 0);
return isAfter(result, now) ? result : null;
}
// "april first week", "first week of april", "march 2nd day", etc.
const mo = text.match(MONTH_ORDINAL_RE);
if (mo) {
// Groups: (1)month-A (2)ordinal-A (3)unit-A | (4)ordinal-B (5)unit-B (6)month-B (7)time
const monthIdx = MONTH_MAP[mo[1] || mo[6]];
const num = parseOrdinal(mo[2] || mo[4]);
const unit = mo[3] || mo[5];
const timeStr = mo[7];
if (!num || num < 1) return null;
if (unit === 'day') {
if (num > 31) return null;
return resolveAbsoluteDate(monthIdx, num, null, timeStr, now);
}
// unit === 'week'
if (num > 5) return null;
const weekStartDay = (num - 1) * 7 + 1;
let year = now.getFullYear();
if (
monthIdx < now.getMonth() ||
(monthIdx === now.getMonth() && now.getDate() > weekStartDay)
) {
year += 1;
}
// Reject if weekStartDay overflows the month (e.g. feb fifth week = day 29 in non-leap)
const daysInMonth = new Date(year, monthIdx + 1, 0).getDate();
if (weekStartDay > daysInMonth) return null;
const d = new Date(year, monthIdx, weekStartDay);
if (!isValid(d)) return null;
const result = applyTimeOrDefault(d, timeStr);
return result && isAfter(result, now) ? result : null;
}
return null;
};
/** Build a date from year/month/day numbers, with optional time. */
const buildDateWithOptionalTime = (year, month, day, timeStr) => {
const date = strictDate(year, month, day);
if (!date) return null;
return applyTimeOrDefault(date, timeStr);
};
// When both values are ≤ 12 (ambiguous), dayFirst controls the fallback:
// dayFirst=false (slash M/D/Y) → month first
// dayFirst=true (dash/dot D-M-Y, D.M.Y) → day first
const disambiguateDayMonth = (a, b, dayFirst = false) => {
if (a > 12) return { day: a, month: b - 1 };
if (b > 12) return { month: a - 1, day: b };
return dayFirst ? { day: a, month: b - 1 } : { month: a - 1, day: b };
};
/** Handle formal dates: "2025-01-15", "1/15/2025", "15.01.2025". */
const matchFormalDate = (text, now) => {
const ensureFuture = date => (date && isAfter(date, now) ? date : null);
const isoMatch = text.match(ISO_DATE_RE);
if (isoMatch) {
return ensureFuture(
buildDateWithOptionalTime(
parseInt(isoMatch[1], 10),
parseInt(isoMatch[2], 10) - 1,
parseInt(isoMatch[3], 10),
isoMatch[4]
)
);
}
// Slash = M/D/Y (US), Dash/Dot = D-M-Y / D.M.Y (European)
const formats = [
{ re: SLASH_DATE_RE, dayFirst: false },
{ re: DASH_DATE_RE, dayFirst: true },
{ re: DOT_DATE_RE, dayFirst: true },
];
let result = null;
formats.some(({ re, dayFirst }) => {
const m = text.match(re);
if (!m) return false;
const { month, day } = disambiguateDayMonth(
parseInt(m[1], 10),
parseInt(m[2], 10),
dayFirst
);
result = ensureFuture(
buildDateWithOptionalTime(parseInt(m[3], 10), month, day, m[4])
);
return true;
});
return result;
};
/** Handle "day after tomorrow", "end of week", "this weekend", "later today". */
const matchSpecial = (text, now) => {
const dat = text.match(DAY_AFTER_TOMORROW_RE);
if (dat) return applyTimeOrDefault(add(startOfDay(now), { days: 2 }), dat[1]);
const eof = text.match(END_OF_RE);
if (eof) {
if (eof[1] === 'day') return applyTimeToDate(now, 17, 0);
if (eof[1] === 'week') {
const fri = applyTimeToDate(now, 17, 0);
if (getDay(now) === 5 && isAfter(fri, now)) return fri;
return applyTimeToDate(nextFriday(now), 17, 0);
}
if (eof[1] === 'month') {
const eom = applyTimeToDate(endOfMonth(now), 17, 0);
if (isAfter(eom, now)) return eom;
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
}
}
// "end of next week", "end of next month"
const eofNext = text.match(END_OF_NEXT_RE);
if (eofNext) {
if (eofNext[1] === 'week') {
const nextWeekStart = startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 });
return applyTimeToDate(add(nextWeekStart, { days: 4 }), 17, 0);
}
if (eofNext[1] === 'month') {
return applyTimeToDate(endOfMonth(add(now, { months: 1 })), 17, 0);
}
}
// "beginning of next week", "start of next month"
const sofNext = text.match(START_OF_NEXT_RE);
if (sofNext) {
if (sofNext[1] === 'week') {
return applyTimeToDate(
startOfWeek(addWeeks(now, 1), { weekStartsOn: 1 }),
9,
0
);
}
if (sofNext[1] === 'month') {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return applyTimeToDate(nextMonth, 9, 0);
}
}
// "next business day", "next working day"
if (NEXT_BUSINESS_DAY_RE.test(text)) {
let d = add(startOfDay(now), { days: 1 });
while (isSaturday(d) || isSunday(d)) d = add(d, { days: 1 });
return applyTimeToDate(d, 9, 0);
}
if (LATER_TODAY_RE.test(text)) return add(now, { hours: 3 });
const weekendMatch = text.match(
/^(this weekend|weekend|next weekend)(?:\s+(?:at\s+)?(.+))?$/
);
if (weekendMatch) {
const isNext = weekendMatch[1] === 'next weekend';
const timeStr = weekendMatch[2];
if (isNext) {
const sat = nextSaturday(now);
const d = isSaturday(now) || isSunday(now) ? sat : add(sat, { weeks: 1 });
return applyTimeOrDefault(d, timeStr);
}
if (isSaturday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
if (now.getHours() < 18) return add(now, { hours: 2 });
return applyTimeToDate(add(startOfDay(now), { days: 1 }), 10, 0);
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
return applyTimeOrDefault(add(startOfDay(now), { days: 1 }), timeStr);
}
if (isSunday(now)) {
if (!timeStr) {
if (now.getHours() < 10) return applyTimeToDate(now, 10, 0);
return add(now, { hours: 2 });
}
const today = applyTimeOrDefault(now, timeStr);
if (today && isAfter(today, now)) return today;
}
return applyTimeOrDefault(nextSaturday(now), timeStr);
}
return null;
};
// ─── Main Parser ────────────────────────────────────────────────────────────
// Order matters — first match wins. Common patterns go first.
// Do not reorder without running the spec.
const MATCHERS = [
matchDuration, // "in 2 hours", "half day", "3h30m"
matchSpecial, // "end of week", "later today", "this weekend"
matchRelativeDay, // "tomorrow 3pm", "tonight", "today morning"
matchNextPattern, // "next friday", "next week", "next month"
matchTimeOfDay, // "morning", "evening 6pm", "eod"
matchWeekday, // "friday", "monday 3pm", "wed morning"
matchTimeOnly, // "3pm", "14:30" (must be after weekday to avoid conflicts)
matchNamedDate, // "jan 15", "march 20 next year"
matchFormalDate, // "2025-01-15", "1/15/2025" (least common, last)
];
/**
* Parse free-form text into a future date.
* Returns { date, unix } or null. Only returns dates after referenceDate.
*
* @param {string} text - user input like "in 2 hours" or "next friday 3pm"
* @param {Date} [referenceDate] - treat as "now" (defaults to current time)
* @returns {{ date: Date, unix: number } | null}
*/
export const parseDateFromText = (text, referenceDate = new Date()) => {
if (!text || typeof text !== 'string') return null;
const normalized = stripNoise(sanitize(text));
if (!normalized) return null;
const maxDate = add(referenceDate, { years: 999 });
const isValidFuture = d =>
d && isValid(d) && isAfter(d, referenceDate) && !isBefore(maxDate, d);
let result = null;
MATCHERS.some(matcher => {
const d = matcher(normalized, referenceDate);
if (isValidFuture(d)) {
result = { date: d, unix: getUnixTime(d) };
return true;
}
return false;
});
return result;
};
@@ -0,0 +1,101 @@
/**
* Builds autocomplete suggestions as the user types a snooze date.
* Matches partial input against known phrases and ranks them by closeness.
*/
import {
UNIT_MAP,
WEEKDAY_MAP,
TIME_OF_DAY_MAP,
RELATIVE_DAY_MAP,
WORD_NUMBER_MAP,
MONTH_MAP,
HALF_UNIT_DURATIONS,
} from './tokenMaps';
const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter(
u => u !== 'seconds'
);
const FULL_WEEKDAYS = Object.keys(WEEKDAY_MAP).filter(k => k.length > 3);
const TOD_NAMES = Object.keys(TIME_OF_DAY_MAP).filter(k => !k.includes(' '));
const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3);
const ALL_SUGGESTION_PHRASES = [
...Object.keys(RELATIVE_DAY_MAP),
...FULL_WEEKDAYS,
...TOD_NAMES,
'next week',
'next month',
'this weekend',
'next weekend',
'day after tomorrow',
'later today',
'end of day',
'end of week',
'end of month',
...['morning', 'afternoon', 'evening'].map(tod => `tomorrow ${tod}`),
...FULL_WEEKDAYS.map(wd => `next ${wd}`),
...FULL_WEEKDAYS.map(wd => `this ${wd}`),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `${wd} ${tod}`)),
...FULL_WEEKDAYS.flatMap(wd => TOD_NAMES.map(tod => `next ${wd} ${tod}`)),
...MONTH_NAMES_LONG.map(m => `${m} 1`),
];
/** Check how closely the input matches a candidate. -1 = no match, 0 = exact prefix, N = extra words needed. */
const prefixMatchScore = (candidate, input) => {
if (candidate === input) return -1;
if (candidate.startsWith(input)) return 0;
const inputWords = input.split(' ');
const candidateWords = candidate.split(' ');
const lastIdx = inputWords.reduce((prev, iw) => {
if (prev === -2) return -2;
const idx = candidateWords.findIndex(
(cw, ci) => ci > prev && cw.startsWith(iw)
);
return idx === -1 ? -2 : idx;
}, -1);
if (lastIdx === -2) return -1;
return candidateWords.length - inputWords.length;
};
export const MAX_SUGGESTIONS = 5;
/** Turn user input into a ranked list of suggestion strings to try parsing. */
export const buildSuggestionCandidates = text => {
if (!text) return [];
if (/^\d/.test(text)) {
const num = text.match(/^\d+(?:\.5)?/)[0];
const candidates = SUGGESTION_UNITS.map(u => `${num} ${u}`);
const trimmed = text.replace(/\s+/g, ' ').trim();
const spaced = trimmed.replace(/(\d)([a-z])/i, '$1 $2');
return spaced.length > num.length
? candidates.filter(c => c.startsWith(spaced))
: candidates;
}
if (text.length >= 2 && 'half'.startsWith(text)) {
return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`);
}
const wordNum = WORD_NUMBER_MAP[text];
if (wordNum != null && wordNum >= 1) {
return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`);
}
const scored = ALL_SUGGESTION_PHRASES.reduce((acc, candidate) => {
const score = prefixMatchScore(candidate, text);
if (score >= 0) acc.push({ candidate, score });
return acc;
}, []);
scored.sort((a, b) => a.score - b.score);
const seen = new Set();
return scored.reduce((acc, { candidate }) => {
if (acc.length < MAX_SUGGESTIONS * 3 && !seen.has(candidate)) {
seen.add(candidate);
acc.push(candidate);
}
return acc;
}, []);
};
@@ -0,0 +1,395 @@
/**
* Shared lookup tables and helper functions used by the parser,
* suggestions, and localization modules.
*/
import {
add,
set,
isValid,
isAfter,
nextMonday,
nextTuesday,
nextWednesday,
nextThursday,
nextFriday,
nextSaturday,
nextSunday,
} from 'date-fns';
// ─── Token Maps ──────────────────────────────────────────────────────────────
// All keys are lowercase. Short forms and full names both work.
/** Weekday name or short form → day index (0 = Sunday). */
export const WEEKDAY_MAP = {
sunday: 0,
sun: 0,
monday: 1,
mon: 1,
tuesday: 2,
tue: 2,
tues: 2,
wednesday: 3,
wed: 3,
thursday: 4,
thu: 4,
thur: 4,
thurs: 4,
friday: 5,
fri: 5,
saturday: 6,
sat: 6,
};
/** Month name or short form → month index (0 = January). */
export const MONTH_MAP = {
january: 0,
jan: 0,
february: 1,
feb: 1,
march: 2,
mar: 2,
april: 3,
apr: 3,
may: 4,
june: 5,
jun: 5,
july: 6,
jul: 6,
august: 7,
aug: 7,
september: 8,
sep: 8,
sept: 8,
october: 9,
oct: 9,
november: 10,
nov: 10,
december: 11,
dec: 11,
};
/** Words like "today" or "tomorrow" → how many days from now. */
export const RELATIVE_DAY_MAP = {
today: 0,
tonight: 0,
tonite: 0,
tomorrow: 1,
tmr: 1,
tmrw: 1,
};
/** Unit shorthand → full unit name used by date-fns. */
export const UNIT_MAP = {
m: 'minutes',
min: 'minutes',
mins: 'minutes',
minute: 'minutes',
minutes: 'minutes',
h: 'hours',
hr: 'hours',
hrs: 'hours',
hour: 'hours',
hours: 'hours',
d: 'days',
day: 'days',
days: 'days',
w: 'weeks',
wk: 'weeks',
wks: 'weeks',
week: 'weeks',
weeks: 'weeks',
mo: 'months',
month: 'months',
months: 'months',
y: 'years',
yr: 'years',
yrs: 'years',
year: 'years',
years: 'years',
};
/** English number words → their numeric value. */
export const WORD_NUMBER_MAP = {
a: 1,
an: 1,
one: 1,
couple: 2,
few: 3,
two: 2,
three: 3,
four: 4,
five: 5,
six: 6,
seven: 7,
eight: 8,
nine: 9,
ten: 10,
eleven: 11,
twelve: 12,
thirteen: 13,
fourteen: 14,
fifteen: 15,
sixteen: 16,
seventeen: 17,
eighteen: 18,
nineteen: 19,
twenty: 20,
thirty: 30,
forty: 40,
fifty: 50,
sixty: 60,
ninety: 90,
half: 0.5,
};
/** Day index → the date-fns function that finds the next occurrence. */
export const NEXT_WEEKDAY_FN = {
0: nextSunday,
1: nextMonday,
2: nextTuesday,
3: nextWednesday,
4: nextThursday,
5: nextFriday,
6: nextSaturday,
};
/** Time-of-day label → default hour and minute. */
export const TIME_OF_DAY_MAP = {
morning: { hours: 9, minutes: 0 },
noon: { hours: 12, minutes: 0 },
afternoon: { hours: 14, minutes: 0 },
evening: { hours: 18, minutes: 0 },
night: { hours: 20, minutes: 0 },
tonight: { hours: 20, minutes: 0 },
midnight: { hours: 0, minutes: 0 },
eod: { hours: 17, minutes: 0 },
'end of day': { hours: 17, minutes: 0 },
'end of the day': { hours: 17, minutes: 0 },
};
/** Allowed hour range per label — used to pick am or pm when not specified. */
export const TOD_HOUR_RANGE = {
morning: [4, 12],
noon: [11, 13],
afternoon: [12, 18],
evening: [16, 22],
night: [18, 24],
tonight: [18, 24],
midnight: [23, 25],
};
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
export const HALF_UNIT_DURATIONS = {
hour: { minutes: 30 },
day: { hours: 12 },
week: { days: 3, hours: 12 },
month: { days: 15 },
year: { months: 6 },
};
const FRACTIONAL_CONVERT = {
hours: { unit: 'minutes', factor: 60 },
days: { unit: 'hours', factor: 24 },
weeks: { unit: 'days', factor: 7 },
months: { unit: 'days', factor: 30 },
years: { unit: 'months', factor: 12 },
};
// ─── Unicode / Normalization ────────────────────────────────────────────────
// Turn non-ASCII digits and punctuation into plain ASCII so the
// parser only has to deal with standard characters.
const UNICODE_DIGIT_RANGES = [
[0x30, 0x39],
[0x660, 0x669], // Arabic-Indic
[0x6f0, 0x6f9], // Eastern Arabic-Indic
[0x966, 0x96f], // Devanagari
[0x9e6, 0x9ef], // Bengali
[0xa66, 0xa6f], // Gurmukhi
[0xae6, 0xaef], // Gujarati
[0xb66, 0xb6f], // Oriya
[0xbe6, 0xbef], // Tamil
[0xc66, 0xc6f], // Telugu
[0xce6, 0xcef], // Kannada
[0xd66, 0xd6f], // Malayalam
];
const toAsciiDigit = char => {
const code = char.codePointAt(0);
const range = UNICODE_DIGIT_RANGES.find(
([start, end]) => code >= start && code <= end
);
if (!range) return char;
return String(code - range[0]);
};
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
const ARABIC_PUNCT_MAP = {
'\u061f': '?',
'\u060c': ',',
'\u061b': ';',
'\u066b': '.',
};
const NOISE_RE =
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push)(?:\s+(?:it|this))?\s+)?(?:(?:on|to|for|at|until|till|by|from|after|within)\s+)?/;
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
export const sanitize = text =>
normalizeDigits(
text
.normalize('NFKC')
.toLowerCase()
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
.replace(/[\u064b-\u065f]/g, '')
.replace(/\u00a0/g, ' ')
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
)
.replace(/[,!?;]+/g, ' ')
.replace(/\.+$/g, '')
.replace(/\s+/g, ' ')
.trim();
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
export const stripNoise = text => {
let r = text
.replace(/\ba\s+fortnight\b/g, '2 weeks')
.replace(/\bfortnight\b/g, '2 weeks')
.replace(NOISE_RE, '')
.replace(APPROX_RE, '')
.replace(/^the\s+/, '')
.replace(/\bnxt\b/g, 'next')
.replace(/\ba\s+couple\s+of\b/g, 'couple')
.replace(/\bcouple\s+of\b/g, 'couple')
.replace(/\ba\s+couple\b/g, 'couple')
.replace(/\ba\s+few\b/g, 'few')
.replace(
/\b(\d+)\s*(?:h|hr|hours?)[\s]*(\d+)\s*(?:m|min|minutes?)\b/g,
(_, h, m) =>
`${h} ${h === '1' ? 'hour' : 'hours'} ${m} ${m === '1' ? 'minute' : 'minutes'}`
)
.replace(/\b(\d+)h\b/g, (_, h) => `${h} ${h === '1' ? 'hour' : 'hours'}`)
.replace(
/\b(\d+)m\b/g,
(_, m) => `${m} ${m === '1' ? 'minute' : 'minutes'}`
)
.replace(/\btomm?orow\b/g, 'tomorrow')
.replace(/\s+later$/, '')
.trim();
// bare unit without number: "month later" → "1 month", "week" stays
r = r.replace(/^(minutes?|hours?|days?|weeks?|months?|years?)$/, '1 $1');
return r;
};
// ─── Utility Functions ──────────────────────────────────────────────────────
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
export const parseNumber = str => {
if (!str) return null;
const lower = normalizeDigits(str.toLowerCase().trim());
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
const num = Number(lower);
return Number.isNaN(num) ? null : num;
};
/** Set the time on a date, clearing seconds and milliseconds. */
export const applyTimeToDate = (date, hours, minutes = 0) =>
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
export const parseTimeString = timeStr => {
if (!timeStr) return null;
const match = timeStr
.toLowerCase()
.replace(/\s+/g, '')
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
if (!match) return null;
const raw = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2], 10) : 0;
const meridiem = match[3]?.replace(/\./g, '');
if (meridiem && (raw < 1 || raw > 12)) return null;
const toHours = (h, m) => {
if (m === 'pm' && h < 12) return h + 12;
if (m === 'am' && h === 12) return 0;
return h;
};
const hours = toHours(raw, meridiem);
if (hours > 23 || minutes > 59) return null;
return { hours, minutes };
};
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
export const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
if (timeStr) {
const time = parseTimeString(timeStr);
if (!time) return null;
return applyTimeToDate(date, time.hours, time.minutes);
}
return applyTimeToDate(date, defaultHours, 0);
};
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
export const strictDate = (year, month, day) => {
const date = new Date(year, month, day);
if (
!isValid(date) ||
date.getFullYear() !== year ||
date.getMonth() !== month ||
date.getDate() !== day
)
return null;
return date;
};
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
export const futureOrNextYear = (year, month, day, timeStr, now) => {
for (let i = 0; i < 9; i += 1) {
const base = strictDate(year + i, month, day);
if (base) {
const date = applyTimeOrDefault(base, timeStr);
if (!date) return null;
if (isAfter(date, now)) return date;
}
}
return null;
};
/** If the date is already past, push it to the next day. */
export const ensureFutureOrNextDay = (date, now) =>
isAfter(date, now) ? date : add(date, { days: 1 });
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
const h = parseInt(rawHour, 10);
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
const range = TOD_HOUR_RANGE[todLabel];
if (!range) return { hours: h, minutes: m };
// Try both am and pm interpretations, pick the one in range
const am = h === 12 ? 0 : h;
const pm = h === 12 ? 12 : h + 12;
const inRange = v => v >= range[0] && v < range[1];
if (inRange(am)) return { hours: am, minutes: m };
if (inRange(pm)) return { hours: pm, minutes: m };
const mid = (range[0] + range[1]) / 2;
return {
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
minutes: m,
};
};
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
export const addFractionalSafe = (date, unit, amount) => {
if (Number.isInteger(amount)) return add(date, { [unit]: amount });
if (amount % 1 !== 0.5) return null;
const conv = FRACTIONAL_CONVERT[unit];
if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) });
return add(date, { [unit]: Math.round(amount) });
};
@@ -7,11 +7,17 @@ import {
startOfMonth,
isMonday,
isToday,
isSameYear,
setHours,
setMinutes,
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
import {
generateDateSuggestions,
parseDateFromText,
} from 'dashboard/helper/snoozeDateParser';
import { UNIT_MAP } from 'dashboard/helper/snoozeDateParser/tokenMaps';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -33,65 +39,113 @@ export const findStartOfNextMonth = currentDate => {
});
};
export const findNextDay = currentDate => {
return add(currentDate, { days: 1 });
};
export const findNextDay = currentDate => add(currentDate, { days: 1 });
export const setHoursToNine = date => {
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
export const setHoursToNine = date =>
setSeconds(setMinutes(setHours(date, 9), 0), 0);
const SNOOZE_RESOLVERS = {
[SNOOZE_OPTIONS.AN_HOUR_FROM_NOW]: d => add(d, { hours: 1 }),
[SNOOZE_OPTIONS.UNTIL_TOMORROW]: d => setHoursToNine(findNextDay(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_WEEK]: d => setHoursToNine(findStartOfNextWeek(d)),
[SNOOZE_OPTIONS.UNTIL_NEXT_MONTH]: d =>
setHoursToNine(findStartOfNextMonth(d)),
};
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
let parsedDate = null;
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
parsedDate = add(currentDate, { hours: 1 });
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
parsedDate = setHoursToNine(findNextDay(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
}
return parsedDate ? getUnixTime(parsedDate) : null;
const resolve = SNOOZE_RESOLVERS[snoozeType];
return resolve ? getUnixTime(resolve(currentDate)) : null;
};
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
if (!snoozedUntil) return null;
const date = new Date(snoozedUntil);
if (isToday(date)) return format(date, 'h.mmaaa');
if (!isSameYear(date, new Date())) return format(date, 'd MMM yyyy, h.mmaaa');
return format(date, 'd MMM, h.mmaaa');
};
if (isToday(date)) {
return format(date, 'h.mmaaa');
export const snoozedReopenTimeToTimestamp = snoozedUntil =>
snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const formatSnoozeDate = (snoozeDate, currentDate, locale = 'en') => {
const sameYear = isSameYear(snoozeDate, currentDate);
try {
const opts = {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true,
...(sameYear ? {} : { year: 'numeric' }),
};
return new Intl.DateTimeFormat(locale, opts).format(snoozeDate);
} catch {
return sameYear
? format(snoozeDate, 'EEE, d MMM, h:mm a')
: format(snoozeDate, 'EEE, d MMM yyyy, h:mm a');
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
const expandUnit = (num, abbr) => {
const full = UNIT_MAP[abbr];
if (!full) return `${num} ${abbr}`;
return parseFloat(num) === 1
? `${num} ${full.replace(/s$/, '')}`
: `${num} ${full}`;
};
const capitalizeLabel = text => {
const expanded = text
.replace(
/^(\d+)h(\d+)m(?:in)?$/i,
(_, h, m) => `${expandUnit(h, 'h')} ${expandUnit(m, 'm')}`
)
.replace(/^(\d+(?:\.5)?)\s*([a-z]+)$/i, (_, n, u) =>
UNIT_MAP[u.toLowerCase()] ? expandUnit(n, u.toLowerCase()) : `${n} ${u}`
);
return expanded.replace(/^\w/, c => c.toUpperCase());
};
export const generateSnoozeSuggestions = (
searchText,
currentDate = new Date(),
{ translations, locale } = {}
) => {
const suggestions = generateDateSuggestions(searchText, currentDate, {
translations,
locale,
});
return suggestions.map(s => ({
date: s.date,
unixTime: s.unix,
query: s.query,
label: capitalizeLabel(s.label),
formattedDate: formatSnoozeDate(s.date, currentDate, locale),
resolve: () => parseDateFromText(s.query)?.unix ?? s.unix,
}));
};
const UNIT_SHORT = {
minute: 'm',
minutes: 'm',
hour: 'h',
hours: 'h',
day: 'd',
days: 'd',
month: 'mo',
months: 'mo',
year: 'y',
years: 'y',
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const unitMap = {
minutes: 'm',
minute: 'm',
hours: 'h',
hour: 'h',
days: 'd',
day: 'd',
months: 'mo',
month: 'mo',
years: 'y',
year: 'y',
};
const shortenTime = snoozedUntil
if (!snoozedUntil) return null;
return snoozedUntil
.replace(/^in\s+/i, '')
.replace(
/\s(minute|hour|day|month|year)s?\b/gi,
(match, unit) => unitMap[unit.toLowerCase()] || match
(match, unit) => UNIT_SHORT[unit.toLowerCase()] || match
);
return shortenTime;
};
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ import {
setHoursToNine,
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
generateSnoozeSuggestions,
} from '../snoozeHelpers';
describe('#Snooze Helpers', () => {
@@ -91,12 +92,26 @@ describe('#Snooze Helpers', () => {
});
describe('snoozedReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTime(null)).toEqual(null);
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
});
it('should return formatted date if snoozedUntil is not nil', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should return formatted date with year if snoozedUntil is not in current year', () => {
// Input is 09:00 UTC.
// If your environment is UTC, this will be 9.00am.
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun 2023, 9.00am'
);
});
it('should return formatted date without year if snoozedUntil is in current year', () => {
// This uses 2024 because we mocked the system time above
expect(snoozedReopenTime('2024-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
@@ -150,4 +165,56 @@ describe('#Snooze Helpers', () => {
expect(shortenSnoozeTime(null)).toEqual(null);
});
});
describe('generateSnoozeSuggestions label expansion', () => {
const now = new Date('2023-06-16T10:00:00');
it('expands abbreviated units: "1d" → "1 Day"', () => {
const results = generateSnoozeSuggestions('1d', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 day');
});
it('expands abbreviated units: "2 d" → "2 Days"', () => {
const results = generateSnoozeSuggestions('2 d', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 days');
});
it('expands abbreviated units: "1h" → "1 Hour"', () => {
const results = generateSnoozeSuggestions('1h', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour');
});
it('expands abbreviated units: "2min" → "2 Minutes"', () => {
const results = generateSnoozeSuggestions('2min', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 minutes');
});
it('handles singular: "1 hours" → "1 Hour"', () => {
const results = generateSnoozeSuggestions('1 hours', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour');
});
it('handles singular: "1 minutes" → "1 Minute"', () => {
const results = generateSnoozeSuggestions('1 minutes', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 minute');
});
it('keeps plural for non-1: "2 days" → "2 Days"', () => {
const results = generateSnoozeSuggestions('2 days', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('2 days');
});
it('expands compound: "1h30m" → "1 Hour 30 Minutes"', () => {
const results = generateSnoozeSuggestions('1h30m', now);
expect(results.length).toBeGreaterThan(0);
expect(results[0].label).toBe('1 hour 30 minutes');
});
});
});
@@ -4,6 +4,9 @@
"LOADING_EDITOR": "Loading editor...",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
"LEARN_MORE": "Learn about agent bots",
"COUNT": "{n} bot | {n} bots",
"SEARCH_PLACEHOLDER": "Search bots...",
"NO_RESULTS": "No bots found matching your search",
"GLOBAL_BOT": "System bot",
"GLOBAL_BOT_BADGE": "System",
"AVATAR": {
@@ -34,7 +37,8 @@
"LOADING": "Fetching bots...",
"TABLE_HEADER": {
"DETAILS": "Bot Details",
"URL": "Webhook URL"
"URL": "Webhook URL",
"ACTIONS": "Actions"
}
},
"DELETE": {
@@ -9,6 +9,7 @@
"ADMINISTRATOR": "Administrator",
"AGENT": "Agent"
},
"COUNT": "{n} agent | {n} agents",
"LIST": {
"404": "There are no agents associated to this account",
"TITLE": "Manage agents in your team",
@@ -96,6 +97,8 @@
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"SEARCH_PLACEHOLDER": "Search agents...",
"NO_RESULTS": "No agents found matching your search",
"SEARCH": {
"NO_RESULTS": "No results found."
},
@@ -5,6 +5,9 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"COUNT": "{n} attribute | {n} attributes",
"SEARCH_PLACEHOLDER": "Search attributes...",
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
@@ -63,6 +66,10 @@
},
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
},
"BADGES": {
"PRE_CHAT": "Pre-chat",
"RESOLUTION": "Resolution"
}
},
"API": {
@@ -3,8 +3,11 @@
"HEADER": "Automation",
"DESCRIPTION": "Automation can replace and streamline existing processes that require manual effort, such as adding labels and assigning conversations to the most suitable agent. This allows the team to focus on their strengths while reducing time spent on routine tasks.",
"LEARN_MORE": "Learn more about automation",
"HEADER_BTN_TXT": "Add Automation Rule",
"COUNT": "{n} automation | {n} automations",
"HEADER_BTN_TXT": "Create Automation",
"LOADING": "Fetching automation rules",
"SEARCH_PLACEHOLDER": "Search automation rules...",
"NO_RESULTS": "No automation rules found matching your search",
"ADD": {
"TITLE": "Add Automation Rule",
"SUBMIT": "Create",
@@ -42,9 +45,9 @@
"LIST": {
"TABLE_HEADER": {
"NAME": "Name",
"DESCRIPTION": "Description",
"ACTIVE": "Active",
"CREATED_ON": "Created on"
"CREATED_ON": "Created on",
"ACTIONS": "Actions"
},
"404": "No automation rules found"
},
@@ -150,7 +153,8 @@
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation"
"OPEN_CONVERSATION": "Open conversation",
"PENDING_CONVERSATION": "Mark conversation as pending"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
@@ -22,6 +22,10 @@
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
"RESOLVE": {
"ALL_MISSING_ATTRIBUTES": "Cannot resolve conversations due to missing required attributes",
"PARTIAL_SUCCESS": "Some conversations need required attributes before resolving and were skipped"
},
"LABELS": {
"ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found",
@@ -3,8 +3,11 @@
"HEADER": "Canned Responses",
"LEARN_MORE": "Learn more about canned responses",
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
"COUNT": "{n} canned response | {n} canned responses",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"SEARCH_PLACEHOLDER": "Search canned responses...",
"NO_RESULTS": "No canned responses found matching your search",
"SEARCH_404": "There are no items matching this query.",
"LIST": {
"404": "There are no canned responses available in this account.",
@@ -76,6 +76,9 @@
},
"waiting_since_desc": {
"TEXT": "Pending Response: Shortest first"
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
}
},
"ATTACHMENTS": {
@@ -457,6 +457,9 @@
"INSTAGRAM": {
"PLACEHOLDER": "Add Instagram"
},
"TELEGRAM": {
"PLACEHOLDER": "Add Telegram"
},
"TIKTOK": {
"PLACEHOLDER": "Add TikTok"
},
@@ -573,7 +576,8 @@
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
}
},
"LOAD_MORE": "Load more"
},
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
@@ -607,7 +611,7 @@
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
"CONTACT_SELECTOR": {
"LABEL": "To:",
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
"CONTACT_CREATING": "Creating contact..."
},
"INBOX_SELECTOR": {
@@ -618,9 +622,9 @@
"SUBJECT_LABEL": "Subject :",
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
"CC_LABEL": "Cc:",
"CC_PLACEHOLDER": "Search for a contact with their email address",
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"BCC_LABEL": "Bcc:",
"BCC_PLACEHOLDER": "Search for a contact with their email address",
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
"BCC_BUTTON": "Bcc"
},
"MESSAGE_EDITOR": {
@@ -61,6 +61,7 @@
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
"NO_RESPONSE": "No response",
@@ -173,6 +174,10 @@
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
"FAILED": "Couldn't assign label. Please try again."
},
"LABEL_REMOVAL": {
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
"FAILED": "Couldn't remove label. Please try again."
},
"TEAM_ASSIGNMENT": {
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
"FAILED": "Couldn't assign team. Please try again."
@@ -185,7 +190,11 @@
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
},
@@ -205,7 +214,7 @@
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"": "",
"COPILOT_THINKING": "Copilot is thinking",
"EMAIL_HEAD": {
"TO": "TO",
"ADD_BCC": "Add bcc",
@@ -247,9 +256,12 @@
"SUCCESS_DELETE_CONVERSATION": "ውይይት በተሳካ ሁኔታ ተሰርዟል",
"FAIL_DELETE_CONVERSATION": "ውይይትን መሰረዝ አልተቻለም! እንደገና ይሞክሩ",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"FILE_TYPE_NOT_SUPPORTED": "This {fileName} file type is not supported in this conversation",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
"BOT": "Bot",
"NATIVE_APP": "Native app",
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
"SEND_FAILED": "Couldn't send message! Try again",
"TRY_AGAIN": "retry",
"ASSIGNMENT": {
@@ -294,6 +306,7 @@
"CANCEL": "Cancel",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
"SEND_EMAIL_ERROR": "There was an error, please try again",
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
"FORM": {
"SEND_TO_CONTACT": "Send the transcript to the customer",
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
@@ -3,8 +3,11 @@
"HEADER": "Custom Roles",
"LEARN_MORE": "Learn more about custom roles",
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
"COUNT": "{n} custom role | {n} custom roles",
"HEADER_BTN_TXT": "Add custom role",
"LOADING": "Fetching custom roles...",
"SEARCH_PLACEHOLDER": "Search custom roles...",
"NO_RESULTS": "No custom roles found matching your search",
"SEARCH_404": "There are no items matching this query.",
"PAYWALL": {
"TITLE": "Upgrade to create custom roles",
@@ -1,5 +1,8 @@
{
"DATE_PICKER": {
"PREVIOUS_PERIOD": "Previous period",
"NEXT_PERIOD": "Next period",
"WEEK_NUMBER": "Week #{weekNumber}",
"APPLY_BUTTON": "Apply",
"CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": {
@@ -13,6 +16,8 @@
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
"THIS_WEEK": "This week",
"MONTH_TO_DATE": "This month",
"CUSTOM_RANGE": "Custom date range"
}
}
@@ -7,6 +7,13 @@
},
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
"ACCEPT": "Accept",
"DISCARD": "Discard",
"PREFERRED": "Preferred"
},
"CHOICE_TOGGLE": {
"YES": "Yes",
"NO": "No"
}
}
@@ -182,6 +182,7 @@
},
"COMMAND_BAR": {
"SEARCH_PLACEHOLDER": "Search or jump to",
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
"SECTIONS": {
"GENERAL": "General",
"REPORTS": "Reports",
@@ -374,6 +374,16 @@
"ERROR_MESSAGE": "Error while deleting article"
}
},
"REORDER_ARTICLE": {
"API": {
"ERROR_MESSAGE": "Unable to reorder articles. Please try again."
}
},
"REORDER_CATEGORY": {
"API": {
"ERROR_MESSAGE": "Unable to reorder categories. Please try again."
}
},
"CREATE_ARTICLE": {
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
},
@@ -3,6 +3,9 @@
"HEADER": "Inboxes",
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
"LEARN_MORE": "Learn more about inboxes",
"COUNT": "{n} inbox | {n} inboxes",
"SEARCH_PLACEHOLDER": "Search inboxes...",
"NO_RESULTS": "No inboxes found matching your search",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
@@ -575,7 +578,7 @@
"SUBTITLE": "Use only the configured business name as the sender name in the email header."
},
"BUSINESS_NAME": {
"BUTTON_TEXT": "+ Configure your business name",
"BUTTON_TEXT": "Configure your business name",
"PLACEHOLDER": "Enter your business name",
"SAVE_BUTTON_TEXT": "Save"
}
@@ -589,8 +592,10 @@
"DISABLED": "Disabled"
},
"LOCK_TO_SINGLE_CONVERSATION": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
"ENABLED": "Reopen same conversation",
"DISABLED": "Create new conversations",
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
},
"ENABLE_HMAC": {
"LABEL": "Enable"
@@ -625,6 +630,8 @@
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
@@ -678,6 +685,16 @@
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
},
"WEBHOOK": {
"TITLE": "Webhook Configuration",
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
"ACTION_REQUIRED": "Webhook not configured",
"REGISTER_BUTTON": "Register Webhook",
"REGISTER_SUCCESS": "Webhook registered successfully",
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
"CONFIGURED_SUCCESS": "Webhook configured successfully",
"URL_MISMATCH": "Webhook URL mismatch"
}
},
"SETTINGS": "Settings",
@@ -694,7 +711,7 @@
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
"PLACEHOLDER": "example.com, www.example.com, app.example.com"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
@@ -708,8 +725,8 @@
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "Inbox Settings",
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
@@ -758,6 +775,7 @@
"LABEL": "Help Center",
"PLACEHOLDER": "Select Help Center",
"SELECT_PLACEHOLDER": "Select Help Center",
"NONE": "None",
"REMOVE": "Remove Help Center",
"SUB_TEXT": "Attach a Help Center with the inbox"
},
@@ -766,6 +784,53 @@
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
},
"ASSIGNMENT": {
"TITLE": "Conversation Assignment",
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
"DEFAULT_RULES_TITLE": "Default assignment rules",
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
"DEFAULT_RULE_1": "Earliest created conversations first",
"DEFAULT_RULE_2": "Round robin distribution",
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
"USING_POLICY": "Using custom assignment policy for this inbox",
"CUSTOMIZE_POLICY": "Customize with assignment policy",
"DELETE_POLICY": "Delete policy",
"POLICY_LABEL": "Assignment policy",
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
"POLICY_STATUS": {
"ACTIVE": "Active",
"INACTIVE": "Inactive"
},
"PRIORITY": {
"EARLIEST_CREATED": "Earliest created",
"LONGEST_WAITING": "Longest waiting"
},
"METHOD": {
"ROUND_ROBIN": "Round robin",
"BALANCED": "Balanced assignment"
},
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
"DEFAULT_POLICY_LINKED": "Default policy linked",
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
"LINK_EXISTING_POLICY": "Link existing policy",
"CREATE_NEW_POLICY": "Create new policy",
"NO_POLICIES": "No assignment policies found",
"VIEW_ALL_POLICIES": "View all policies",
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
"LINK_SUCCESS": "Assignment policy linked successfully",
"LINK_ERROR": "Failed to link assignment policy"
},
"ASSIGNMENT_POLICY": {
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
"CANCEL": "Cancel",
"CONFIRM_DELETE": "Delete",
"DELETE_SUCCESS": "Assignment policy removed successfully",
"DELETE_ERROR": "Failed to remove assignment policy"
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
@@ -837,6 +902,20 @@
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"UTILITY_ANALYZER": {
"ACTION": "Check utility fit",
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
"RESULT_LABEL": "Meta category prediction",
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
"APPLY": "Use this rewrite",
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
"CLASSIFICATION": {
"LIKELY_UTILITY": "Likely Utility",
"LIKELY_MARKETING": "Likely Marketing",
"UNCLEAR": "Needs clarification"
}
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +927,7 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -864,9 +943,11 @@
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours visitors can be warned with a message and a pre-chat form.",
"DAY": {
"DAY": "Day",
"AVAILABILITY": "Availability",
"HOURS": "Hours",
"ENABLE": "Enable availability for this day",
"UNAVAILABLE": "Unavailable",
"HOURS": "hours",
"VALIDATION_ERROR": "Starting time should be before closing time.",
"CHOOSE": "Choose"
},
@@ -973,11 +1054,12 @@
"IN_A_DAY": "In a day"
},
"WIDGET_COLOR_LABEL": "Widget Color",
"WIDGET_BUBBLE_POSITION_LABEL": "Widget Bubble Position",
"WIDGET_BUBBLE_TYPE_LABEL": "Widget Bubble Type",
"WIDGET_BUBBLE": "Bubble",
"WIDGET_BUBBLE_POSITION_LABEL": "Position:",
"WIDGET_BUBBLE_TYPE_LABEL": "Type:",
"WIDGET_BUBBLE_LAUNCHER_TITLE": {
"DEFAULT": "Chat with us",
"LABEL": "Widget Bubble Launcher Title",
"LABEL": "Launcher Title",
"PLACE_HOLDER": "Chat with us"
},
"UPDATE": {
@@ -1002,7 +1084,7 @@
},
"WIDGET_SCREEN": {
"DEFAULT": "Default",
"CHAT": "Chat"
"CHAT": "Chat mode"
},
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
@@ -3,6 +3,9 @@
"FETCHING": "Fetching Integrations",
"NO_HOOK_CONFIGURED": "There are no {integrationId} integrations configured in this account.",
"HEADER": "Applications",
"COUNT": "{n} integration | {n} integrations",
"SEARCH_PLACEHOLDER": "Search...",
"NO_RESULTS": "No results found matching your search",
"STATUS": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
@@ -31,6 +34,7 @@
"LIST": {
"FETCHING": "Fetching integration hooks",
"INBOX": "Inbox",
"ACTIONS": "Actions",
"DELETE": {
"BUTTON_TEXT": "Delete"
}
@@ -1,6 +1,7 @@
{
"INTEGRATION_SETTINGS": {
"SHOPIFY": {
"HEADER": "Shopify",
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
@@ -19,6 +20,8 @@
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",
"LOADING": "Fetching integrations",
"SEARCH_PLACEHOLDER": "Search integrations...",
"NO_RESULTS": "No integrations found matching your search",
"CAPTAIN": {
"DISABLED": "Captain is not enabled on your account.",
"CLICK_HERE_TO_CONFIGURE": "Click here to configure",
@@ -28,6 +31,17 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
"DONE": "Done"
},
"COUNT": "{n} webhook | {n} webhooks",
"SEARCH_PLACEHOLDER": "Search webhooks...",
"NO_RESULTS": "No webhooks found matching your search",
"FORM": {
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
@@ -104,6 +118,7 @@
}
},
"SLACK": {
"HEADER": "Slack",
"DELETE": "Delete",
"DELETE_CONFIRMATION": {
"TITLE": "Delete the integration",
@@ -145,7 +160,29 @@
"EXPAND": "Expand",
"MAKE_FRIENDLY": "Change message tone to friendly",
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
"SIMPLIFY": "Simplify",
"CONFIDENT": "Use confident tone",
"PROFESSIONAL": "Use professional tone",
"CASUAL": "Use casual tone",
"STRAIGHTFORWARD": "Use straightforward tone"
},
"REPLY_OPTIONS": {
"IMPROVE_REPLY": "Improve reply",
"IMPROVE_REPLY_SELECTION": "Improve the selection",
"CHANGE_TONE": {
"TITLE": "Change tone",
"OPTIONS": {
"PROFESSIONAL": "Professional",
"CASUAL": "Casual",
"STRAIGHTFORWARD": "Straightforward",
"CONFIDENT": "Confident",
"FRIENDLY": "Friendly"
}
},
"GRAMMAR": "Fix grammar & spelling",
"SUGGESTION": "Suggest a reply",
"SUMMARIZE": "Summarize the conversation",
"ASK_COPILOT": "Ask Copilot"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
@@ -201,12 +238,16 @@
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
"DESCRIPTION": "Dashboard Apps allow organizations to embed an application inside the dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that to provide user information, their orders, or their previous payment history.",
"LEARN_MORE": "Learn more about Dashboard Apps",
"COUNT": "{n} dashboard app | {n} dashboard apps",
"SEARCH_PLACEHOLDER": "Search dashboard apps...",
"NO_RESULTS": "No dashboard apps found matching your search",
"LIST": {
"404": "There are no dashboard apps configured on this account yet",
"LOADING": "Fetching dashboard apps...",
"TABLE_HEADER": {
"NAME": "Name",
"ENDPOINT": "Endpoint"
"ENDPOINT": "Endpoint",
"ACTIONS": "Actions"
},
"EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app"
@@ -243,6 +284,7 @@
}
},
"LINEAR": {
"HEADER": "Linear",
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
"LOADING": "Fetching linear issues...",
"LOADING_ERROR": "There was an error fetching the linear issues, please try again",
@@ -337,6 +379,7 @@
}
},
"NOTION": {
"HEADER": "Notion",
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
@@ -406,6 +449,7 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
@@ -5,6 +5,9 @@
"LOADING": "Fetching labels",
"DESCRIPTION": "Labels help you categorize and prioritize conversations and leads. You can assign a label to a conversation or contact using the side panel.",
"LEARN_MORE": "Learn more about labels",
"COUNT": "{n} label | {n} labels",
"SEARCH_PLACEHOLDER": "Search labels...",
"NO_RESULTS": "No labels found matching your search",
"SEARCH_404": "There are no items matching this query",
"LIST": {
"404": "There are no labels available in this account.",
@@ -13,7 +16,8 @@
"TABLE_HEADER": {
"NAME": "Name",
"DESCRIPTION": "Description",
"COLOR": "Color"
"COLOR": "Color",
"ACTION": "Actions"
}
},
"FORM": {
@@ -3,9 +3,12 @@
"HEADER": "Macros",
"DESCRIPTION": "A macro is a set of saved actions that help customer service agents easily complete tasks. The agents can define a set of actions like tagging a conversation with a label, sending an email transcript, updating a custom attribute, etc., and they can run these actions in a single click.",
"LEARN_MORE": "Learn more about macros",
"COUNT": "{n} macro | {n} macros",
"HEADER_BTN_TXT": "Add a new macro",
"HEADER_BTN_TXT_SAVE": "Save macro",
"LOADING": "Fetching macros",
"SEARCH_PLACEHOLDER": "Search macros...",
"NO_RESULTS": "No macros found matching your search",
"ERROR": "Something went wrong. Please try again",
"ORDER_INFO": "Macros will run in the order you add your actions. You can rearrange them by dragging them by the handle beside each node.",
"ADD": {
@@ -29,7 +32,8 @@
"NAME": "Name",
"CREATED BY": "Created by",
"LAST_UPDATED_BY": "Last updated by",
"VISIBILITY": "Visibility"
"VISIBILITY": "Visibility",
"ACTIONS": "Actions"
},
"404": "No macros found"
},
@@ -1,7 +1,7 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
@@ -3,7 +3,7 @@
"HEADER": "Conversations",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
"DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports",
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
"METRICS": {
@@ -128,11 +128,16 @@
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agents name to learn more.",
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
"FILTER_DROPDOWN_LABEL": "Select Agent",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"AGENTS": "Search agents"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -201,6 +206,11 @@
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
"FILTER_DROPDOWN_LABEL": "Select Label",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"LABELS": "Search labels"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -271,6 +281,11 @@
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"INBOXES": "Search inboxes"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -334,11 +349,19 @@
},
"TEAM_REPORTS": {
"HEADER": "Team Overview",
"DESCRIPTION": "Get a snapshot of your teams performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
"DESCRIPTION": "Get a snapshot of your team's performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
"FILTER_DROPDOWN_LABEL": "Select Team",
"FILTERS": {
"ADD_FILTER": "Add filter",
"CLEAR_ALL": "Clear all",
"NO_FILTER": "No filters available",
"INPUT_PLACEHOLDER": {
"TEAMS": "Search teams"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -402,22 +425,48 @@
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
"NO_RECORDS": "No responses yet",
"NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.",
"DOWNLOAD": "Download CSAT Reports",
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
"FILTERS": {
"ADD_FILTER": "Add filter",
"CLEAR_ALL": "Clear all",
"NO_FILTER": "No filters available",
"INPUT_PLACEHOLDER": {
"AGENTS": "Search agents",
"INBOXES": "Search inboxes",
"TEAMS": "Search teams",
"RATINGS": "Search ratings"
},
"AGENTS": {
"PLACEHOLDER": "Choose Agents"
"LABEL": "Agent"
},
"INBOXES": {
"LABEL": "Inbox"
},
"TEAMS": {
"LABEL": "Team"
},
"RATINGS": {
"LABEL": "Rating"
}
},
"TABLE": {
"HEADER": {
"CONTACT_NAME": "Contact",
"AGENT_NAME": "Assigned agent",
"AGENT_NAME": "Agent",
"RATING": "Rating",
"FEEDBACK_TEXT": "Feedback comment"
}
"FEEDBACK_TEXT": "Feedback comment",
"CONVERSATION": "Conversation",
"CUSTOMER": "Customer",
"RESPONSE": "Response",
"HANDLED_BY": "Handled by"
},
"UNKNOWN_CUSTOMER": "Unknown customer"
},
"NO_AGENT": "No assigned agent",
"NO_FEEDBACK": "No feedback provided",
"METRIC": {
"TOTAL_RESPONSES": {
"LABEL": "Total responses",
@@ -430,6 +479,25 @@
"RESPONSE_RATE": {
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
},
"RATING_DISTRIBUTION": "Rating distribution"
},
"REVIEW_NOTES": {
"TITLE": "Review notes",
"PLACEHOLDER": "Add review notes about this rating...",
"SAVE": "Save",
"CANCEL": "Cancel",
"SAVING": "Saving...",
"SAVED": "Notes saved successfully",
"SAVE_ERROR": "Failed to save notes",
"UPDATED_BY": "Updated by {name} {time}",
"UPDATED_BY_LABEL": "Updated by",
"PAYWALL": {
"TITLE": "Upgrade to add review notes",
"AVAILABLE_ON": "The review notes feature is only available in the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Add internal context to every CSAT response with review notes. Capture what really happened, spot patterns faster, and make better decisions from your feedback.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
}
}
},
@@ -273,7 +273,8 @@
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading...",
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available.",
"INSTAGRAM_STORY_REPLY": "Replied to your story:"
},
"LOCATION_BUBBLE": {
"SEE_ON_MAP": "See on map"
@@ -378,7 +379,57 @@
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs",
"SECURITY": "Security"
"SECURITY": "Security",
"CAPTAIN_AI": "Captain",
"CONVERSATION_WORKFLOW": "Conversation Workflow"
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
"LOADING": "Loading Captain configuration...",
"LINK_TEXT": "Learn more about Captain Credits",
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
"MODEL_CONFIG": {
"TITLE": "Model Configuration",
"DESCRIPTION": "Select AI models for different features.",
"SELECT_MODEL": "Select model",
"CREDITS_PER_MESSAGE": "{credits} credit/message",
"COMING_SOON": "Coming soon",
"EDITOR": {
"TITLE": "Editor Features",
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
},
"ASSISTANT": {
"TITLE": "Assistant",
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
},
"COPILOT": {
"TITLE": "Co-pilot",
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
}
},
"FEATURES": {
"TITLE": "Features",
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
"MODEL_TITLE": "Label Suggestion Model",
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
}
},
"API": {
"SUCCESS": "Captain settings updated successfully.",
"ERROR": "Failed to update Captain settings. Please try again."
}
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -506,6 +557,58 @@
}
}
},
"CONVERSATION_WORKFLOW": {
"INDEX": {
"HEADER": {
"TITLE": "Conversation Workflows",
"DESCRIPTION": "Configure rules and required fields for conversation resolution."
}
},
"REQUIRED_ATTRIBUTES": {
"TITLE": "Attributes required on resolution",
"DESCRIPTION": "When resolving a conversation, agents will be prompted to fill these attributes if they haven't yet.",
"NO_ATTRIBUTES": "No attributes added yet",
"ADD": {
"TITLE": "Add Attributes",
"SEARCH_PLACEHOLDER": "Search attributes"
},
"SAVE": {
"SUCCESS": "Required attributes updated",
"ERROR": "Could not update required attributes, please try again"
},
"MODAL": {
"TITLE": "Resolve conversation",
"DESCRIPTION": "Please fill in the following custom attributes before resolving this conversation",
"ACTIONS": {
"RESOLVE": "Resolve conversation",
"CANCEL": "Cancel"
},
"PLACEHOLDERS": {
"TEXT": "Write a note...",
"NUMBER": "Enter a number",
"LINK": "Add a link",
"DATE": "Pick a date",
"LIST": "Select an option"
},
"CHECKBOX": {
"YES": "Yes",
"NO": "No"
}
},
"PAYWALL": {
"TITLE": "Upgrade to use required attributes",
"AVAILABLE_ON": "The required conversation attributes feature is available on the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to prompt agents to fill required attributes before conversation resolution.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The required conversation attributes feature is available on the paid plans.",
"UPGRADE_PROMPT": "Upgrade to a paid plan to enforce required attributes before conversation resolution.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",
@@ -591,7 +694,8 @@
"CREATE_BUTTON": "Create policy",
"API": {
"SUCCESS_MESSAGE": "Assignment policy created successfully",
"ERROR_MESSAGE": "Failed to create assignment policy"
"ERROR_MESSAGE": "Failed to create assignment policy",
"INBOX_LINKED": "Inbox has been linked to the policy"
}
},
"EDIT": {
@@ -605,6 +709,12 @@
"CONFIRM_BUTTON_LABEL": "Continue",
"CANCEL_BUTTON_LABEL": "Cancel"
},
"INBOX_LINK_PROMPT": {
"TITLE": "Link inbox to policy",
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
"LINK_BUTTON": "Link inbox",
"CANCEL_BUTTON": "Skip"
},
"API": {
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
"ERROR_MESSAGE": "Failed to update assignment policy"
@@ -643,7 +753,9 @@
},
"BALANCED": {
"LABEL": "Balanced",
"DESCRIPTION": "Assign conversations based on available capacity."
"DESCRIPTION": "Assign conversations based on available capacity.",
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
"PREMIUM_BADGE": "Premium"
}
},
"ASSIGNMENT_PRIORITY": {
@@ -729,6 +841,20 @@
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
"ERROR_MESSAGE": "Failed to remove agent from policy"
}
},
"INBOX_LIMIT_API": {
"ADD": {
"SUCCESS_MESSAGE": "Inbox limit added successfully",
"ERROR_MESSAGE": "Failed to add inbox limit"
},
"UPDATE": {
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
"ERROR_MESSAGE": "Failed to update inbox limit"
},
"DELETE": {
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
"ERROR_MESSAGE": "Failed to delete inbox limit"
}
}
},
"FORM": {
@@ -1,6 +1,7 @@
{
"REGISTER": {
"TRY_WOOT": "Create an account",
"GET_STARTED": "Get started with Chatwoot",
"TITLE": "Register",
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
@@ -5,7 +5,12 @@
"ADD_ACTION_LONG": "Create a new SLA Policy",
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
"LEARN_MORE": "Learn more about SLA",
"COUNT": "{n} SLA | {n} SLAs",
"LOADING": "Fetching SLAs",
"SEARCH_PLACEHOLDER": "Search SLA...",
"SEARCH": {
"NO_RESULTS": "No SLA found matching your search"
},
"PAYWALL": {
"TITLE": "Upgrade to create SLAs",
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
@@ -20,14 +25,18 @@
},
"LIST": {
"404": "There are no SLAs available in this account.",
"TABLE_HEADER": {
"SLA": "SLA",
"BUSINESS_HOURS": "Business hours"
},
"EMPTY": {
"TITLE_1": "Enterprise P0",
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
"TITLE_2": "Enterprise P1",
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
},
"BUSINESS_HOURS_ON": "Business hours on",
"BUSINESS_HOURS_OFF": "Business hours off",
"BUSINESS_HOURS_ON": "Turned on",
"BUSINESS_HOURS_OFF": "Turned off",
"RESPONSE_TYPES": {
"FRT": "First response time threshold",
"NRT": "Next response time threshold",
@@ -0,0 +1,72 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "minute",
"MINUTES": "minutes",
"HOUR": "hour",
"HOURS": "hours",
"DAY": "day",
"DAYS": "days",
"WEEK": "week",
"WEEKS": "weeks",
"MONTH": "month",
"MONTHS": "months",
"YEAR": "year",
"YEARS": "years"
},
"HALF": "half",
"NEXT": "next",
"THIS": "this",
"AT": "at",
"IN": "in",
"FROM_NOW": "from now",
"NEXT_YEAR": "next year",
"MERIDIEM": {
"AM": "am",
"PM": "pm"
},
"RELATIVE": {
"TOMORROW": "tomorrow",
"DAY_AFTER_TOMORROW": "day after tomorrow",
"NEXT_WEEK": "next week",
"NEXT_MONTH": "next month",
"THIS_WEEKEND": "this weekend",
"NEXT_WEEKEND": "next weekend"
},
"TIME_OF_DAY": {
"MORNING": "morning",
"AFTERNOON": "afternoon",
"EVENING": "evening",
"NIGHT": "night",
"NOON": "noon",
"MIDNIGHT": "midnight"
},
"WORD_NUMBERS": {
"ONE": "one",
"TWO": "two",
"THREE": "three",
"FOUR": "four",
"FIVE": "five",
"SIX": "six",
"SEVEN": "seven",
"EIGHT": "eight",
"NINE": "nine",
"TEN": "ten",
"TWELVE": "twelve",
"FIFTEEN": "fifteen",
"TWENTY": "twenty",
"THIRTY": "thirty"
},
"ORDINALS": {
"FIRST": "first",
"SECOND": "second",
"THIRD": "third",
"FOURTH": "fourth",
"FIFTH": "fifth"
},
"OF": "of",
"AFTER": "after",
"WEEK": "week",
"DAY": "day"
}
}
@@ -5,6 +5,9 @@
"LOADING": "Fetching teams",
"DESCRIPTION": "Teams allow you to organize agents into groups based on their responsibilities. An agent can belong to multiple teams. When working collaboratively, you can assign conversations to specific teams.",
"LEARN_MORE": "Learn more about teams",
"COUNT": "{n} team | {n} teams",
"SEARCH_PLACEHOLDER": "Search teams...",
"NO_RESULTS": "No teams found matching your search",
"LIST": {
"404": "There are no teams created on this account.",
"EDIT_TEAM": "Edit team",
@@ -64,8 +67,8 @@
"ERROR_MESSAGE": "Couldn't save the team details. Try again."
},
"AGENTS": {
"AGENT": "AGENT",
"EMAIL": "EMAIL",
"AGENT": "Agent",
"EMAIL": "Email",
"BUTTON_TEXT": "Add agents",
"ADD_AGENTS": "Adding Agents to your Team...",
"SELECT": "select",
@@ -4,6 +4,9 @@
"LOADING_EDITOR": "جار جلب المحرر...",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
"LEARN_MORE": "Learn about agent bots",
"COUNT": "{n} bot | {n} bots",
"SEARCH_PLACEHOLDER": "Search bots...",
"NO_RESULTS": "No bots found matching your search",
"GLOBAL_BOT": "System bot",
"GLOBAL_BOT_BADGE": "النظام",
"AVATAR": {
@@ -34,7 +37,8 @@
"LOADING": "جار جلب الروبوتات...",
"TABLE_HEADER": {
"DETAILS": "Bot Details",
"URL": "رابط Webhook"
"URL": "رابط Webhook",
"ACTIONS": "الإجراءات"
}
},
"DELETE": {
@@ -9,6 +9,7 @@
"ADMINISTRATOR": "المدير",
"AGENT": "وكيل الدعم"
},
"COUNT": "{n} وكيل | {n} وكلاء",
"LIST": {
"404": "لا يوجد وكلاء دعم مرتبطين بهذا الحساب",
"TITLE": "إدارة وكلاء الدعم في فريقك",
@@ -96,6 +97,8 @@
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
}
},
"SEARCH_PLACEHOLDER": "البحث عن وكلاء...",
"NO_RESULTS": "No agents found matching your search",
"SEARCH": {
"NO_RESULTS": "لم يتم العثور على النتائج."
},
@@ -5,6 +5,9 @@
"LOADING": "جلب الصفات المخصصة",
"DESCRIPTION": "سمة مخصصة تتتبع تفاصيل إضافية حول جهات الاتصال أو المحادثات الخاصة بك - مثل خطة الاشتراك أو تاريخ الشراء الأول. يمكنك إضافة أنواع مختلفة من السمات المخصصة، مثل النص أو القوائم أو الأرقام، لالتقاط المعلومات المحددة التي تحتاجها.",
"LEARN_MORE": "تعرف على المزيد حول السمات المخصصة",
"COUNT": "{n} attribute | {n} attributes",
"SEARCH_PLACEHOLDER": "البحث عن صفات...",
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "المحادثات",
"CONTACT": "جهات الاتصال"
@@ -63,6 +66,10 @@
},
"ENABLE_REGEX": {
"LABEL": "تمكين التحقق من صحة regex"
},
"BADGES": {
"PRE_CHAT": "Pre-chat",
"RESOLUTION": "Resolution"
}
},
"API": {
@@ -23,52 +23,52 @@
},
"DEFAULT_USER": "النظام",
"AUTOMATION_RULE": {
"ADD": "{agentName} أنشأ قاعدة أتمتة جديدة (##{id})",
"EDIT": "{agentName} قام بتحديث قاعدة أتمتة (##{id})",
"DELETE": "{agentName} حذف قاعدة أتمتة (##{id})"
"ADD": "{agentName} أنشأ قاعدة أتمتة جديدة (#{id})",
"EDIT": "{agentName} قام بتحديث قاعدة أتمتة (#{id})",
"DELETE": "{agentName} حذف قاعدة أتمتة (#{id})"
},
"ACCOUNT_USER": {
"ADD": "{agentName} دعا {invitee} إلى الحساب كـ {role}",
"EDIT": {
"SELF": "{agentName} غير {attributes} الخاصة به إلى {values}",
"OTHER": "{agentName} غير {attributes} لـ {user} إلى {values}",
"DELETED": "{agentName} غير {attributes} لـ %{user} إلى {values}"
"DELETED": "{agentName} غير {attributes} للمستخدم المحذوف إلى {values}"
}
},
"INBOX": {
"ADD": "{agentName} أنشأ صندوق وارد جديد (##{id})",
"EDIT": "{agentName} قام بتحديث صندوق الوارد (##{id})",
"DELETE": "{agentName} حذف صندوق الوارد (##{id})"
"ADD": "{agentName} أنشأ صندوق وارد جديد (#{id})",
"EDIT": "{agentName} قام بتحديث صندوق الوارد (#{id})",
"DELETE": "{agentName} حذف صندوق الوارد (#{id})"
},
"WEBHOOK": {
"ADD": "{agentName} أنشأ Webhook جديد (##{id})",
"EDIT": "{agentName} قام بتحديث Webhook (##{id})",
"DELETE": "{agentName} حذف Webhook (##{id})"
"ADD": "{agentName} أنشأ Webhook جديد (#{id})",
"EDIT": "{agentName} قام بتحديث Webhook (#{id})",
"DELETE": "{agentName} حذف Webhook (#{id})"
},
"USER_ACTION": {
"SIGN_IN": "{agentName} قام بتسجيل الدخول",
"SIGN_OUT": "{agentName} قام بتسجيل الخروج"
},
"TEAM": {
"ADD": "{agentName} أنشأ فريق جديد (##{id})",
"EDIT": "{agentName} قام بتحديث الفريق (##{id})",
"DELETE": "{agentName} حذف الفريق (##{id})"
"ADD": "{agentName} أنشأ فريق جديد (#{id})",
"EDIT": "{agentName} قام بتحديث الفريق (#{id})",
"DELETE": "{agentName} حذف الفريق (#{id})"
},
"MACRO": {
"ADD": "{agentName} أنشأ ماكرو جديد (##{id})",
"EDIT": "{agentName} قام بتحديث ماكرو (##{id})",
"DELETE": "{agentName} حذف ماكرو (##{id})"
"ADD": "{agentName} أنشأ ماكرو جديد (#{id})",
"EDIT": "{agentName} قام بتحديث ماكرو (#{id})",
"DELETE": "{agentName} حذف ماكرو (#{id})"
},
"INBOX_MEMBER": {
"ADD": "{agentName} أضاف {user} إلى صندوق الوارد (##{inbox_id})",
"REMOVE": "{agentName} أزال {user} من صندوق الوارد (##{inbox_id})"
"ADD": "{agentName} أضاف {user} إلى صندوق الوارد (#{inbox_id})",
"REMOVE": "{agentName} أزال {user} من صندوق الوارد (#{inbox_id})"
},
"TEAM_MEMBER": {
"ADD": "{agentName} أضاف {user} إلى الفريق (##{team_id})",
"REMOVE": "{agentName} أزال {user} من الفريق (##{team_id})"
"ADD": "{agentName} أضاف {user} إلى الفريق (#{team_id})",
"REMOVE": "{agentName} أزال {user} من الفريق (#{team_id})"
},
"ACCOUNT": {
"EDIT": "{agentName} قام بتحديث إعدادات الحساب (##{id})"
"EDIT": "{agentName} قام بتحديث إعدادات الحساب (#{id})"
},
"CONVERSATION": {
"DELETE": "{agentName} deleted conversation #{id}"
@@ -3,8 +3,11 @@
"HEADER": "الأتمتة",
"DESCRIPTION": "ويمكن للأتمتة أن تحل محل وتبسط العمليات القائمة التي تتطلب جهداً يدوياً، مثل إضافة تسميات وتعيين المحادثات إلى أنسب وكيل. ويسمح ذلك للفريق بالتركيز على مواطن قوتهم مع تقليل الوقت الذي يقضيه في المهام الروتينية.",
"LEARN_MORE": "تعلم المزيد عن الأتمتة",
"HEADER_BTN_TXT": "إضافة قاعدة أتمتة",
"COUNT": "{n} automation | {n} automations",
"HEADER_BTN_TXT": "Create Automation",
"LOADING": "جلب قواعد الأتمتة",
"SEARCH_PLACEHOLDER": "Search automation rules...",
"NO_RESULTS": "No automation rules found matching your search",
"ADD": {
"TITLE": "إضافة قاعدة أتمتة",
"SUBMIT": "إنشاء",
@@ -42,9 +45,9 @@
"LIST": {
"TABLE_HEADER": {
"NAME": "الاسم",
"DESCRIPTION": "الوصف",
"ACTIVE": "مفعل",
"CREATED_ON": "تم إنشاؤها في"
"CREATED_ON": "تم إنشاؤها في",
"ACTIONS": "الإجراءات"
},
"404": "لم يتم العثور على قواعد أتمتة"
},
@@ -150,7 +153,8 @@
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "تغيير الأولوية",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "فتح المحادثة"
"OPEN_CONVERSATION": "فتح المحادثة",
"PENDING_CONVERSATION": "تحديد المحادثة كمعلقة"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
@@ -22,6 +22,10 @@
"UPDATE_SUCCESFUL": "تم تحديث حالة المحادثة بنجاح.",
"UPDATE_FAILED": "فشل تحديث المحادثات، الرجاء المحاولة مرة أخرى."
},
"RESOLVE": {
"ALL_MISSING_ATTRIBUTES": "لا يمكن حل المحادثات بسبب عدم وجود السمات المطلوبة",
"PARTIAL_SUCCESS": "بعض المحادثات تحتاج إلى سمات مطلوبة قبل الحل وتم تخطيها"
},
"LABELS": {
"ASSIGN_LABELS": "إضافة وسم",
"NO_LABELS_FOUND": "لم يتم العثور على تصنيفات",

Some files were not shown because too many files have changed in this diff Show More