feat: add trigger after time passed (#7452)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Johannes
2026-03-11 03:12:31 -07:00
committed by GitHub
parent cb41e2d344
commit 3e3c696972
24 changed files with 523 additions and 1 deletions

View File

@@ -47,6 +47,7 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
pageDwell: t("environments.actions.time_on_page"),
});
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {

View File

@@ -569,10 +569,15 @@ checksums:
environments/actions/test_match: 401f40fa811c3945bda3e019f242e388
environments/actions/test_your_url: 4c35b97b614d4f20f92aa449cc748e8d
environments/actions/this_action_was_created_automatically_you_cannot_make_changes_to_it: 6eddf2420a18408b9c8d9db6ab5ca34b
environments/actions/this_action_will_be_triggered_after_user_stays_on_page: 3ae729d24e828fdd473a233078f91074
environments/actions/this_action_will_be_triggered_when_the_page_is_loaded: 8d28f30cf56f50ea79aef8dd2b02185d
environments/actions/this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page: 60230ad129853377af1066e10f3b3c22
environments/actions/this_action_will_be_triggered_when_the_user_tries_to_leave_the_page: 504fa734659524933e3489823d820265
environments/actions/this_is_a_code_action_please_make_changes_in_your_code_base: 27e64863fdebca791ca129ac0c3da3d5
environments/actions/time_in_seconds: 822be76950e5f614ed23f52ba1d4825f
environments/actions/time_in_seconds_placeholder: e54a4c40e0c6b43fb2e97bd32cab8da8
environments/actions/time_in_seconds_with_unit: a743b7844c71ddad364a93872682ae9e
environments/actions/time_on_page: b712a3e809669be7384a52a801777408
environments/actions/track_new_user_action: 7133de99e7561128cfc6631ab2e592de
environments/actions/track_user_action_to_display_surveys_or_create_user_segment: 2794a2dc51e48f1178d5d759db68029c
environments/actions/url: ca97457614226960d41dd18c3c29c86b

View File

@@ -601,10 +601,15 @@
"test_match": "Testspiel",
"test_your_url": "Teste deine URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Diese Aktion wurde automatisch erstellt. Du kannst keine Änderungen daran vornehmen.",
"this_action_will_be_triggered_after_user_stays_on_page": "Diese Aktion wird ausgelöst, nachdem der Nutzer für die angegebene Dauer auf der Seite geblieben ist.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Diese Aktion wird ausgelöst, wenn die Seite geladen ist.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Diese Aktion wird ausgelöst, wenn der Benutzer 50% der Seite scrollt.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Diese Aktion wird ausgelöst, wenn der Benutzer versucht, die Seite zu verlassen.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Dies ist eine Code-Aktion. Bitte nehmen Sie Änderungen an Ihrem Code vor.",
"time_in_seconds": "Zeit in Sekunden",
"time_in_seconds_placeholder": "z.B. 10",
"time_in_seconds_with_unit": "{seconds} s",
"time_on_page": "Verweildauer auf Seite",
"track_new_user_action": "Neue Benutzeraktion verfolgen",
"track_user_action_to_display_surveys_or_create_user_segment": "Benutzeraktionen verfolgen, um Umfragen anzuzeigen oder Benutzersegmente zu erstellen.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Test Match",
"test_your_url": "Test your URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "This action was created automatically. You cannot make changes to it.",
"this_action_will_be_triggered_after_user_stays_on_page": "This action will be triggered after the user stays on the page for the specified duration.",
"this_action_will_be_triggered_when_the_page_is_loaded": "This action will be triggered when the page is loaded.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "This action will be triggered when the user scrolls 50% of the page.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "This action will be triggered when the user tries to leave the page.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "This is a code action. Please make changes in your code base.",
"time_in_seconds": "Time in seconds",
"time_in_seconds_placeholder": "e.g. 10",
"time_in_seconds_with_unit": "{seconds}s",
"time_on_page": "Time on Page",
"track_new_user_action": "Track New User Action",
"track_user_action_to_display_surveys_or_create_user_segment": "Track user action to display surveys or create user segment.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Probar coincidencia",
"test_your_url": "Prueba tu URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Esta acción se creó automáticamente. No puedes realizar cambios en ella.",
"this_action_will_be_triggered_after_user_stays_on_page": "Esta acción se activará después de que el usuario permanezca en la página durante el tiempo especificado.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Esta acción se activará cuando se cargue la página.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Esta acción se activará cuando el usuario desplace el 50 % de la página.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Esta acción se activará cuando el usuario intente abandonar la página.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Esta es una acción de código. Por favor, realiza cambios en tu base de código.",
"time_in_seconds": "Tiempo en segundos",
"time_in_seconds_placeholder": "p. ej. 10",
"time_in_seconds_with_unit": "{seconds}s",
"time_on_page": "Tiempo en la página",
"track_new_user_action": "Seguir nueva acción de usuario",
"track_user_action_to_display_surveys_or_create_user_segment": "Seguir acción de usuario para mostrar encuestas o crear segmento de usuarios.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Match de test",
"test_your_url": "Test de l'URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Cette action a été créée automatiquement. Vous ne pouvez pas y apporter de modifications.",
"this_action_will_be_triggered_after_user_stays_on_page": "Cette action sera déclenchée après que l'utilisateur reste sur la page pendant la durée spécifiée.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Cette action se déclenche quand une page est chargée.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Cette action se déclenche quand un utilisateur consulte 50 % d'une page.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Cette action se déclenche quand un utilisateur tente de quitter une page.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ceci est une action de code. Veuillez apporter des modifications à votre base de code.",
"time_in_seconds": "Temps en secondes",
"time_in_seconds_placeholder": "par ex. 10",
"time_in_seconds_with_unit": "{seconds} s",
"time_on_page": "Temps sur la page",
"track_new_user_action": "Suivi des actions d'un utilisateur",
"track_user_action_to_display_surveys_or_create_user_segment": "Vous pouvez suivre les actions d'un utilisateur pour afficher des enquêtes ou créer des segments ad hoc.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Illeszkedés tesztelése",
"test_your_url": "URL tesztelése",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.",
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet akkor fog aktiválódni, miután a felhasználó a megadott ideig az oldalon tartózkodik.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.",
"time_in_seconds": "Idő másodpercben",
"time_in_seconds_placeholder": "pl. 10",
"time_in_seconds_with_unit": "{seconds} mp",
"time_on_page": "Oldalon töltött idő",
"track_new_user_action": "Új felhasználói művelet követése",
"track_user_action_to_display_surveys_or_create_user_segment": "Felhasználói művelet követése a kérdőívek megjelenítéséhez vagy felhasználói szakasz létrehozásához.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "一致をテスト",
"test_your_url": "URLをテスト",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "このアクションは自動的に作成されました。変更を加えることはできません。",
"this_action_will_be_triggered_after_user_stays_on_page": "このアクションは、ユーザーが指定された時間ページに滞在した後にトリガーされます。",
"this_action_will_be_triggered_when_the_page_is_loaded": "このアクションは、ページが読み込まれたときにトリガーされます。",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "このアクションは、ユーザーがページの50%をスクロールしたときにトリガーされます。",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "このアクションは、ユーザーがページを離れようとしたときにトリガーされます。",
"this_is_a_code_action_please_make_changes_in_your_code_base": "これはコードアクションです。コードベースで変更を行ってください。",
"time_in_seconds": "秒数",
"time_in_seconds_placeholder": "例: 10",
"time_in_seconds_with_unit": "{seconds}秒",
"time_on_page": "ページ滞在時間",
"track_new_user_action": "新しいユーザーアクションを追跡",
"track_user_action_to_display_surveys_or_create_user_segment": "ユーザーアクションを追跡してフォームを表示したり、ユーザーセグメントを作成したりします。",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Testwedstrijd",
"test_your_url": "Test uw URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Deze actie is automatisch aangemaakt. U kunt er geen wijzigingen in aanbrengen.",
"this_action_will_be_triggered_after_user_stays_on_page": "Deze actie wordt geactiveerd nadat de gebruiker gedurende de opgegeven tijd op de pagina blijft.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Deze actie wordt geactiveerd wanneer de pagina wordt geladen.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Deze actie wordt geactiveerd wanneer de gebruiker 50% van de pagina scrollt.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Deze actie wordt geactiveerd wanneer de gebruiker de pagina probeert te verlaten.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Dit is een codeactie. Breng wijzigingen aan in uw codebasis.",
"time_in_seconds": "Tijd in seconden",
"time_in_seconds_placeholder": "bijv. 10",
"time_in_seconds_with_unit": "{seconds}s",
"time_on_page": "Tijd op pagina",
"track_new_user_action": "Volg nieuwe gebruikersactie",
"track_user_action_to_display_surveys_or_create_user_segment": "Volg gebruikersacties om enquêtes weer te geven of een gebruikerssegment te creëren.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Partida de Teste",
"test_your_url": "Teste sua URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Essa ação foi criada automaticamente. Você não pode fazer alterações nela.",
"this_action_will_be_triggered_after_user_stays_on_page": "Esta ação será acionada depois que o usuário permanecer na página pelo tempo especificado.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Essa ação vai ser disparada quando a página carregar.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Essa ação vai ser acionada quando o usuário rolar 50% da página.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Essa ação será acionada quando o usuário tentar sair da página.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Esta é uma ação de código. Por favor, faça alterações na sua base de código.",
"time_in_seconds": "Tempo em segundos",
"time_in_seconds_placeholder": "ex: 10",
"time_in_seconds_with_unit": "{seconds}s",
"time_on_page": "Tempo na Página",
"track_new_user_action": "Rastrear Ação de Novo Usuário",
"track_user_action_to_display_surveys_or_create_user_segment": "Rastrear ações do usuário para exibir pesquisas ou criar segmento de usuários.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Testar correspondência",
"test_your_url": "Testar o seu URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Esta ação foi criada automaticamente. Não pode fazer alterações a esta ação.",
"this_action_will_be_triggered_after_user_stays_on_page": "Esta ação será acionada depois de o utilizador permanecer na página durante o tempo especificado.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Esta ação será desencadeada quando a página for carregada.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Esta ação será desencadeada quando o utilizador rolar 50% da página.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Esta ação será desencadeada quando o utilizador tentar sair da página.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Esta é uma ação de código. Por favor, faça alterações na sua base de código.",
"time_in_seconds": "Tempo em segundos",
"time_in_seconds_placeholder": "ex. 10",
"time_in_seconds_with_unit": "{seconds}s",
"time_on_page": "Tempo na Página",
"track_new_user_action": "Rastrear Nova Ação do Utilizador",
"track_user_action_to_display_surveys_or_create_user_segment": "Rastrear ação do utilizador para exibir inquéritos ou criar segmento de utilizador.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Testează potrivirea",
"test_your_url": "Testează URL-ul tău",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Această acțiune a fost creată automat. Nu puteți face modificări la aceasta.",
"this_action_will_be_triggered_after_user_stays_on_page": "Această acțiune va fi declanșată după ce utilizatorul rămâne pe pagină pentru durata specificată.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Această acțiune va fi declanșată atunci când pagina este încărcată.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Această acțiune va fi declanșată atunci când utilizatorul derulează 50% din pagină.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Această acțiune va fi declanșată atunci când utilizatorul încearcă să părăsească pagina.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Aceasta este o acțiune de cod. Vă rugăm să faceți modificări în baza dvs. de cod.",
"time_in_seconds": "Timp în secunde",
"time_in_seconds_placeholder": "ex. 10",
"time_in_seconds_with_unit": "{seconds}s",
"time_on_page": "Timp pe pagină",
"track_new_user_action": "Urmăriți acțiunea noului utilizator",
"track_user_action_to_display_surveys_or_create_user_segment": "Urmăriți acțiunea utilizatorului pentru a afișa sondaje sau a crea un segment de utilizator.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Проверить соответствие",
"test_your_url": "Проверьте свой URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Это действие создано автоматически. Вы не можете вносить в него изменения.",
"this_action_will_be_triggered_after_user_stays_on_page": "Это действие будет запущено после того, как пользователь пробудет на странице в течение указанного времени.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Это действие будет выполнено при загрузке страницы.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Это действие будет выполнено, когда пользователь прокрутит 50% страницы.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Это действие будет выполнено, когда пользователь попытается покинуть страницу.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Это действие с кодом. Пожалуйста, внесите изменения в ваш код.",
"time_in_seconds": "Время в секундах",
"time_in_seconds_placeholder": "например, 10",
"time_in_seconds_with_unit": "{seconds} с",
"time_on_page": "Время на странице",
"track_new_user_action": "Отслеживать новое действие пользователя",
"track_user_action_to_display_surveys_or_create_user_segment": "Отслеживайте действия пользователя для показа опросов или создания сегмента пользователей.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "Testa matchning",
"test_your_url": "Testa din URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Denna åtgärd skapades automatiskt. Du kan inte göra ändringar i den.",
"this_action_will_be_triggered_after_user_stays_on_page": "Denna åtgärd utlöses när användaren har stannat kvar på sidan under den angivna tiden.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Denna åtgärd utlöses när sidan laddas.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Denna åtgärd utlöses när användaren scrollar 50% av sidan.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Denna åtgärd utlöses när användaren försöker lämna sidan.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Detta är en kodåtgärd. Vänligen gör ändringar i din kodbas.",
"time_in_seconds": "Tid i sekunder",
"time_in_seconds_placeholder": "t.ex. 10",
"time_in_seconds_with_unit": "{seconds}s",
"time_on_page": "Tid på sidan",
"track_new_user_action": "Spåra ny användaråtgärd",
"track_user_action_to_display_surveys_or_create_user_segment": "Spåra användaråtgärder för att visa enkäter eller skapa användarsegment.",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "测试匹配",
"test_your_url": "测试你的URL",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "此操作是自动创建的。您 不能对此进行更改。",
"this_action_will_be_triggered_after_user_stays_on_page": "此操作将在用户停留在页面达到指定时长后触发。",
"this_action_will_be_triggered_when_the_page_is_loaded": "页面 加载 时,将 触发 此 操作",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "当 用户 滚动 页面 50% 时,将 触发 此 操作",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "当 用户 试图 离开 页面 时,将 触发 此 操作",
"this_is_a_code_action_please_make_changes_in_your_code_base": "这是一个代码操作。请在您的代码库中进行更改。",
"time_in_seconds": "时长(秒)",
"time_in_seconds_placeholder": "例如 10",
"time_in_seconds_with_unit": "{seconds}秒",
"time_on_page": "页面停留时间",
"track_new_user_action": "跟踪 新用户 操作",
"track_user_action_to_display_surveys_or_create_user_segment": "跟踪 用户 操作以显示调查 或 创建用户段",
"url": "URL",

View File

@@ -601,10 +601,15 @@
"test_match": "測試比對",
"test_your_url": "測試您的網址",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "此操作是自動建立的。您無法對其進行變更。",
"this_action_will_be_triggered_after_user_stays_on_page": "此動作將在使用者停留於頁面達到指定時間後觸發。",
"this_action_will_be_triggered_when_the_page_is_loaded": "當頁面載入時,將觸發此操作。",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "當使用者捲動頁面 50% 時,將觸發此操作。",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "當使用者嘗試離開頁面時,將觸發此操作。",
"this_is_a_code_action_please_make_changes_in_your_code_base": "這是一個 code 動作。請在您的 code base 中進行更改。",
"time_in_seconds": "時間(秒)",
"time_in_seconds_placeholder": "例如10",
"time_in_seconds_with_unit": "{seconds} 秒",
"time_on_page": "頁面停留時間",
"track_new_user_action": "追蹤新使用者操作",
"track_user_action_to_display_surveys_or_create_user_segment": "追蹤使用者操作以顯示問卷或建立使用者區隔。",
"url": "網址",

View File

@@ -33,11 +33,16 @@ export const ActionClassInfo = ({ actionClass, className = "" }: ActionClassInfo
};
const isNoCodeClick = actionClass.type === "noCode" && actionClass.noCodeConfig?.type === "click";
const isNoCodeTimeOnPage = actionClass.type === "noCode" && actionClass.noCodeConfig?.type === "pageDwell";
const clickConfig = isNoCodeClick
? (actionClass.noCodeConfig as Extract<typeof actionClass.noCodeConfig, { type: "click" }>)
: null;
const timeOnPageConfig = isNoCodeTimeOnPage
? (actionClass.noCodeConfig as Extract<typeof actionClass.noCodeConfig, { type: "pageDwell" }>)
: null;
return (
<div className={`mt-1 text-xs text-slate-500 ${className}`}>
{actionClass.description && <span className="mr-1">{actionClass.description}</span>}
@@ -60,6 +65,17 @@ export const ActionClassInfo = ({ actionClass, className = "" }: ActionClassInfo
</InfoItem>
)}
{timeOnPageConfig && (
<InfoItem>
{t("environments.actions.time_in_seconds")}:{" "}
<b>
{t("environments.actions.time_in_seconds_with_unit", {
seconds: timeOnPageConfig.timeInSeconds,
})}
</b>
</InfoItem>
)}
{renderUrlFilters()}
</div>
);

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { TabToggle } from "@/modules/ui/components/tab-toggle";
import { CssSelector } from "./components/css-selector";
@@ -40,6 +41,7 @@ export const NoCodeActionForm = ({ form, isReadOnly }: NoCodeActionFormProps) =>
{ value: "pageView", label: t("environments.actions.page_view") },
{ value: "exitIntent", label: t("environments.actions.exit_intent") },
{ value: "fiftyPercentScroll", label: t("environments.actions.fifty_percent_scroll") },
{ value: "pageDwell", label: t("environments.actions.time_on_page") },
]}
/>
</div>
@@ -95,6 +97,42 @@ export const NoCodeActionForm = ({ form, isReadOnly }: NoCodeActionFormProps) =>
</AlertDescription>
</Alert>
)}
{watch("noCodeConfig.type") === "pageDwell" && (
<div className="flex flex-col gap-2">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertTitle>{t("environments.actions.time_on_page")}</AlertTitle>
<AlertDescription>
{t("environments.actions.this_action_will_be_triggered_after_user_stays_on_page")}
</AlertDescription>
</Alert>
<FormField
control={control}
name="noCodeConfig.timeInSeconds"
render={({ field }) => (
<FormItem>
<Label>{t("environments.actions.time_in_seconds")}</Label>
<FormControl>
<Input
type="number"
min={1}
max={3600}
placeholder={t("environments.actions.time_in_seconds_placeholder")}
disabled={isReadOnly}
{...field}
value={field.value ?? ""}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? undefined : Number(val));
}}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
)}
<PageUrlSelector form={form} isReadOnly={isReadOnly} />
</div>
</div>

View File

@@ -7,6 +7,7 @@ import {
addExitIntentListener,
addPageUrlEventListeners,
addScrollDepthListener,
clearTimeOnPageTimers,
removeClickEventListener,
removeExitIntentListener,
removePageUrlEventListeners,
@@ -34,6 +35,7 @@ export const addCleanupEventListeners = (): void => {
removeClickEventListener();
removeExitIntentListener();
removeScrollDepthListener();
clearTimeOnPageTimers();
});
areRemoveEventListenersAdded = true;
};
@@ -47,6 +49,7 @@ export const removeCleanupEventListeners = (): void => {
removeClickEventListener();
removeExitIntentListener();
removeScrollDepthListener();
clearTimeOnPageTimers();
});
areRemoveEventListenersAdded = false;
};
@@ -58,5 +61,6 @@ export const removeAllEventListeners = (): void => {
removeClickEventListener();
removeExitIntentListener();
removeScrollDepthListener();
clearTimeOnPageTimers();
removeCleanupEventListeners();
};

View File

@@ -31,6 +31,7 @@ vi.mock("@/lib/survey/no-code-action", () => ({
removeExitIntentListener: vi.fn(),
addScrollDepthListener: vi.fn(),
removeScrollDepthListener: vi.fn(),
clearTimeOnPageTimers: vi.fn(),
}));
// We'll need to track if "areRemoveEventListenersAdded" was set
@@ -121,6 +122,7 @@ describe("event-listeners file", () => {
const mockClickRemove = vi.spyOn(pageUrlEventListeners, "removeClickEventListener");
const mockExitRemove = vi.spyOn(pageUrlEventListeners, "removeExitIntentListener");
const mockScrollRemove = vi.spyOn(pageUrlEventListeners, "removeScrollDepthListener");
const mockTimeOnPageClear = vi.spyOn(pageUrlEventListeners, "clearTimeOnPageTimers");
// Call the function after setting up the spies
removeAllEventListeners();
@@ -132,6 +134,7 @@ describe("event-listeners file", () => {
expect(mockClickRemove).toHaveBeenCalled();
expect(mockExitRemove).toHaveBeenCalled();
expect(mockScrollRemove).toHaveBeenCalled();
expect(mockTimeOnPageClear).toHaveBeenCalled();
});
test("removeAllEventListeners also calls removeCleanupEventListeners", () => {

View File

@@ -6,6 +6,7 @@ import { TimeoutStack } from "@/lib/common/timeout-stack";
import { evaluateNoCodeConfigClick, handleUrlFilters } from "@/lib/common/utils";
import { trackNoCodeAction } from "@/lib/survey/action";
import { setIsSurveyRunning } from "@/lib/survey/widget";
import { type TEnvironmentStateActionClass } from "@/types/config";
import { type Result } from "@/types/error";
// Factory for creating context-specific tracking handlers
@@ -28,6 +29,22 @@ const trackNoCodePageViewActionHandler = createTrackNoCodeActionWithContext("pag
const trackNoCodeClickActionHandler = createTrackNoCodeActionWithContext("click");
const trackNoCodeExitIntentActionHandler = createTrackNoCodeActionWithContext("exit intent");
const trackNoCodeScrollActionHandler = createTrackNoCodeActionWithContext("scroll");
const trackNoCodeTimeOnPageActionHandler = createTrackNoCodeActionWithContext("time on page");
// Time on Page timer state per action name
interface TimeOnPageRunning {
status: "running";
pageKey: string;
timerId: ReturnType<typeof setTimeout>;
}
interface TimeOnPageFired {
status: "fired";
pageKey: string;
}
type TimeOnPageState = TimeOnPageRunning | TimeOnPageFired;
const timeOnPageTimers = new Map<string, TimeOnPageState>();
// Event types for various listeners
const events = ["hashchange", "popstate", "pushstate", "replacestate", "load"];
@@ -39,6 +56,69 @@ export const setIsHistoryPatched = (value: boolean): void => {
isHistoryPatched = value;
};
const checkTimeOnPage = (actionClasses: TEnvironmentStateActionClass[]): void => {
const queue = CommandQueue.getInstance();
const logger = Logger.getInstance();
const timeoutStack = TimeoutStack.getInstance();
const noCodeTimeOnPageActionClasses = actionClasses.filter(
(action) => action.type === "noCode" && action.noCodeConfig?.type === "pageDwell"
);
const currentPageKey = window.location.href;
const matchingTimeOnPageActionNames = new Set<string>();
for (const event of noCodeTimeOnPageActionClasses) {
const config = event.noCodeConfig as Extract<typeof event.noCodeConfig, { type: "pageDwell" }>;
const { urlFilters, urlFiltersConnector: connector } = config;
const isValidUrl = handleUrlFilters(urlFilters, connector ?? "or");
if (!isValidUrl) continue;
matchingTimeOnPageActionNames.add(event.name);
const existing = timeOnPageTimers.get(event.name);
if (existing?.pageKey === currentPageKey) continue;
if (existing?.status === "running") {
logger.debug(`Time on page timer for "${event.name}" restarting — page changed to ${currentPageKey}`);
clearTimeout(existing.timerId);
}
const { timeInSeconds } = config;
const actionName = event.name;
logger.debug(`Starting time on page timer for "${actionName}" (${timeInSeconds.toString()}s)`);
const timerId = globalThis.setTimeout(() => {
logger.debug(
`Time on page timer for "${actionName}" completed after ${timeInSeconds.toString()}s — firing action`
);
timeOnPageTimers.set(actionName, { status: "fired", pageKey: currentPageKey });
void queue.add(trackNoCodeTimeOnPageActionHandler, CommandType.GeneralAction, true, actionName);
}, timeInSeconds * 1000);
timeOnPageTimers.set(actionName, { status: "running", pageKey: currentPageKey, timerId });
}
for (const [actionName, entry] of timeOnPageTimers) {
if (matchingTimeOnPageActionNames.has(actionName)) continue;
if (entry.status === "running") {
logger.debug(
`Time on page timer for "${actionName}" interrupted — user navigated away before completion`
);
clearTimeout(entry.timerId);
}
timeOnPageTimers.delete(actionName);
const scheduledTimeout = timeoutStack.getTimeouts().find((t) => t.event === actionName);
if (!scheduledTimeout) continue;
timeoutStack.remove(scheduledTimeout.timeoutId);
setIsSurveyRunning(false);
}
};
export const checkPageUrl = async (): Promise<Result<void, unknown>> => {
const queue = CommandQueue.getInstance();
const appConfig = Config.getInstance();
@@ -71,6 +151,8 @@ export const checkPageUrl = async (): Promise<Result<void, unknown>> => {
}
}
checkTimeOnPage(actionClasses);
return { ok: true, data: undefined };
};
@@ -258,3 +340,13 @@ export const removeScrollDepthListener = (): void => {
scrollDepthListenerAdded = false;
}
};
// Time on Page Cleanup
export const clearTimeOnPageTimers = (): void => {
for (const [, entry] of timeOnPageTimers) {
if (entry.status === "running") {
clearTimeout(entry.timerId);
}
}
timeOnPageTimers.clear();
};

View File

@@ -11,6 +11,7 @@ import {
addPageUrlEventListeners,
addScrollDepthListener,
checkPageUrl,
clearTimeOnPageTimers,
createTrackNoCodeActionWithContext,
removeClickEventListener,
removeExitIntentListener,
@@ -654,3 +655,280 @@ describe("removePageUrlEventListeners additional cases", () => {
(window.removeEventListener as Mock).mockRestore();
});
});
describe("time on page action handling", () => {
let getInstanceConfigMock: MockInstance<() => Config>;
let getInstanceTimeoutStackMock: MockInstance<() => TimeoutStack>;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
clearTimeOnPageTimers();
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
getInstanceTimeoutStackMock = vi.spyOn(TimeoutStack, "getInstance");
(checkSetup as Mock).mockReturnValue({ ok: true });
const mockTimeoutStack = {
getTimeouts: vi.fn().mockReturnValue([]),
remove: vi.fn(),
add: vi.fn(),
};
getInstanceTimeoutStackMock.mockReturnValue(mockTimeoutStack as unknown as TimeoutStack);
});
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
clearTimeOnPageTimers();
});
const createConfigWithTimeOnPageAction = (actionName: string, timeInSeconds: number) => ({
get: vi.fn().mockReturnValue({
environment: {
data: {
actionClasses: [
{
name: actionName,
type: "noCode",
noCodeConfig: {
type: "pageDwell",
urlFilters: [{ value: "/dashboard", rule: "contains" }],
timeInSeconds,
},
},
],
},
},
}),
update: vi.fn(),
});
test("starts a timer when URL matches a time on page action", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 5);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
expect(trackNoCodeAction).not.toHaveBeenCalled();
});
test("fires trackNoCodeAction after the time on page elapses", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 5);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
expect(trackNoCodeAction).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(5000);
expect(trackNoCodeAction).toHaveBeenCalledWith("dwellAction");
});
test("does not start a timer if URL does not match", async () => {
(handleUrlFilters as Mock).mockReturnValue(false);
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 5);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/settings" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
await vi.advanceTimersByTimeAsync(5000);
expect(trackNoCodeAction).not.toHaveBeenCalled();
});
test("clears time on page timer when URL stops matching on subsequent navigation", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 10);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
await vi.advanceTimersByTimeAsync(3000);
expect(trackNoCodeAction).not.toHaveBeenCalled();
(handleUrlFilters as Mock).mockReturnValue(false);
await checkPageUrl();
await vi.advanceTimersByTimeAsync(10000);
expect(trackNoCodeAction).not.toHaveBeenCalled();
});
test("does not restart timer when URL still matches on subsequent checkPageUrl calls", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 5);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
await vi.advanceTimersByTimeAsync(2000);
await checkPageUrl();
await vi.advanceTimersByTimeAsync(3000);
expect(trackNoCodeAction).toHaveBeenCalledTimes(1);
expect(trackNoCodeAction).toHaveBeenCalledWith("dwellAction");
});
test("restarts time on page timer when page URL changes but still matches the filter", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 5);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard/1" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
await vi.advanceTimersByTimeAsync(3000);
expect(trackNoCodeAction).not.toHaveBeenCalled();
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard/2" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
await vi.advanceTimersByTimeAsync(3000);
expect(trackNoCodeAction).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000);
expect(trackNoCodeAction).toHaveBeenCalledTimes(1);
expect(trackNoCodeAction).toHaveBeenCalledWith("dwellAction");
});
test("restarts timer after navigating away and back", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 5);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
await vi.advanceTimersByTimeAsync(2000);
(handleUrlFilters as Mock).mockReturnValue(false);
await checkPageUrl();
expect(trackNoCodeAction).not.toHaveBeenCalled();
(handleUrlFilters as Mock).mockReturnValue(true);
await checkPageUrl();
await vi.advanceTimersByTimeAsync(5000);
expect(trackNoCodeAction).toHaveBeenCalledTimes(1);
expect(trackNoCodeAction).toHaveBeenCalledWith("dwellAction");
});
test("does not re-fire after timer already fired on same page", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 3);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
await vi.advanceTimersByTimeAsync(3000);
expect(trackNoCodeAction).toHaveBeenCalledTimes(1);
await checkPageUrl();
await vi.advanceTimersByTimeAsync(3000);
expect(trackNoCodeAction).toHaveBeenCalledTimes(1);
});
test("clearTimeOnPageTimers clears all active timers", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 5);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
clearTimeOnPageTimers();
await vi.advanceTimersByTimeAsync(5000);
expect(trackNoCodeAction).not.toHaveBeenCalled();
});
test("clears scheduled survey display timeout when URL stops matching a time on page action", async () => {
(handleUrlFilters as Mock).mockReturnValue(true);
(trackNoCodeAction as Mock).mockResolvedValue({ ok: true });
const mockConfig = createConfigWithTimeOnPageAction("dwellAction", 5);
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
const mockTimeoutStack = {
getTimeouts: vi.fn().mockReturnValue([{ event: "dwellAction", timeoutId: 999 }]),
remove: vi.fn(),
add: vi.fn(),
};
getInstanceTimeoutStackMock.mockReturnValue(mockTimeoutStack as unknown as TimeoutStack);
vi.stubGlobal("window", {
location: { href: "https://example.com/dashboard" },
setTimeout: globalThis.setTimeout,
});
await checkPageUrl();
(handleUrlFilters as Mock).mockReturnValue(false);
await checkPageUrl();
expect(mockTimeoutStack.remove).toHaveBeenCalledWith(999);
expect(setIsSurveyRunning).toHaveBeenCalledWith(false);
});
});

View File

@@ -78,6 +78,15 @@ export type TActionClassNoCodeConfig =
rule: TActionClassPageUrlRule;
}[];
urlFiltersConnector?: "or" | "and";
}
| {
type: "pageDwell";
urlFilters: {
value: string;
rule: TActionClassPageUrlRule;
}[];
urlFiltersConnector?: "or" | "and";
timeInSeconds: number;
};
export interface TTrackProperties {

View File

@@ -27,7 +27,7 @@ export const ZActionClassPageUrlRule = z.enum(ACTION_CLASS_PAGE_URL_RULES);
export type TActionClassPageUrlRule = z.infer<typeof ZActionClassPageUrlRule>;
const ZActionClassNoCodeConfigBase = z.object({
type: z.enum(["click", "pageView", "exitIntent", "fiftyPercentScroll"]),
type: z.enum(["click", "pageView", "exitIntent", "fiftyPercentScroll", "pageDwell"]),
urlFilters: z.array(
z.object({
value: z.string().trim().min(1, {
@@ -68,11 +68,17 @@ const ZActionClassNoCodeConfigfiftyPercentScroll = ZActionClassNoCodeConfigBase.
type: z.literal("fiftyPercentScroll"),
});
const ZActionClassNoCodeConfigTimeOnPage = ZActionClassNoCodeConfigBase.extend({
type: z.literal("pageDwell"),
timeInSeconds: z.number().int().min(1).max(3600),
});
export const ZActionClassNoCodeConfig = z.union([
ZActionClassNoCodeConfigClick,
ZActionClassNoCodeConfigPageView,
ZActionClassNoCodeConfigExitIntent,
ZActionClassNoCodeConfigfiftyPercentScroll,
ZActionClassNoCodeConfigTimeOnPage,
]);
export type TActionClassNoCodeConfig = z.infer<typeof ZActionClassNoCodeConfig>;