From 1cf1d8961754bd801ea10f0cce066a80a0ba4778 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 23 Feb 2026 19:15:43 +0100 Subject: [PATCH 1/7] feat(frontend): add trial banner with eye-tracking and sparkle CTA - Add TrialBanner component with emoji-style eyes that follow the cursor - Pupils scale up when cursor approaches the CTA button - Always-on sparkle particles and shimmer effect on View Plans button - Add i18n keys for trial banner message and CTA - Mount banner on dashboard page for trial users - Hide Expert-as-a-Service CTA for non-paying orgs --- messages/en.json | 2 + src/components.d.ts | 2 + src/components/dashboard/TrialBanner.vue | 400 ++++++++++++++++++++++ src/pages/dashboard.vue | 3 + src/pages/settings/organization/Plans.vue | 2 +- 5 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 src/components/dashboard/TrialBanner.vue diff --git a/messages/en.json b/messages/en.json index 89c5d4e959..cec5ac3bf8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1400,6 +1400,8 @@ "transfer-app-ownership": "Transfer app ownership", "transfer-app-ownership-requirements": "To transfer an app between organizations, you must have super_admin privileges in both the source and destination organizations. This ensures secure transfer of ownership and prevents unauthorized access.", "transfer-app-ownership-too-soon": "You can only transfer apps every 32 days", + "trial-banner-cta": "View plans", + "trial-banner-message": "Enjoying your Capgo trial? Subscribe to a plan.", "trial-end-date": "Trial End Date", "trial-left": "days left", "trial-organizations-list": "Trial Organizations List", diff --git a/src/components.d.ts b/src/components.d.ts index 0ed5952053..1672586aab 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -76,6 +76,7 @@ declare module 'vue' { TabSidebar: typeof import('./components/TabSidebar.vue')['default'] Toast: typeof import('./components/Toast.vue')['default'] Toggle: typeof import('./components/Toggle.vue')['default'] + TrialBanner: typeof import('./components/dashboard/TrialBanner.vue')['default'] UpdateStatsCard: typeof import('./components/dashboard/UpdateStatsCard.vue')['default'] UpdateStatsChart: typeof import('./components/dashboard/UpdateStatsChart.vue')['default'] Usage: typeof import('./components/dashboard/Usage.vue')['default'] @@ -152,6 +153,7 @@ declare global { const TabSidebar: typeof import('./components/TabSidebar.vue')['default'] const Toast: typeof import('./components/Toast.vue')['default'] const Toggle: typeof import('./components/Toggle.vue')['default'] + const TrialBanner: typeof import('./components/dashboard/TrialBanner.vue')['default'] const UpdateStatsCard: typeof import('./components/dashboard/UpdateStatsCard.vue')['default'] const UpdateStatsChart: typeof import('./components/dashboard/UpdateStatsChart.vue')['default'] const Usage: typeof import('./components/dashboard/Usage.vue')['default'] diff --git a/src/components/dashboard/TrialBanner.vue b/src/components/dashboard/TrialBanner.vue new file mode 100644 index 0000000000..92b7149e6f --- /dev/null +++ b/src/components/dashboard/TrialBanner.vue @@ -0,0 +1,400 @@ + + + + + diff --git a/src/pages/dashboard.vue b/src/pages/dashboard.vue index 31115ece57..df39e5845b 100644 --- a/src/pages/dashboard.vue +++ b/src/pages/dashboard.vue @@ -90,6 +90,9 @@ displayStore.defaultBack = '/apps' + + +
diff --git a/src/pages/settings/organization/Plans.vue b/src/pages/settings/organization/Plans.vue index be9468c30a..7b46e8b619 100644 --- a/src/pages/settings/organization/Plans.vue +++ b/src/pages/settings/organization/Plans.vue @@ -430,7 +430,7 @@ function buttonStyle(p: Database['public']['Tables']['plans']['Row']) { -
+

From 7849be4fa11a1da30341581cda1608d115050bc1 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 23 Feb 2026 19:24:28 +0100 Subject: [PATCH 2/7] revert: show Expert as a Service CTA to all users on Plans page --- src/pages/settings/organization/Plans.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/settings/organization/Plans.vue b/src/pages/settings/organization/Plans.vue index 7b46e8b619..be9468c30a 100644 --- a/src/pages/settings/organization/Plans.vue +++ b/src/pages/settings/organization/Plans.vue @@ -430,7 +430,7 @@ function buttonStyle(p: Database['public']['Tables']['plans']['Row']) { -

+

From 3ab393d84d67995f32df1d8e28e86b3a7702815f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 24 Feb 2026 08:51:04 +0100 Subject: [PATCH 3/7] fix(trial-banner): use org created_at for age check, fix mousemove leak & time reactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use org.created_at instead of subscription_start for the 3-hour age gate. subscription_start was the billing-cycle anchor (bc.cycle_start), which defaults to the start of the current month for trial orgs without Stripe subscriptions — causing the banner to show immediately after signup. - Add created_at to get_orgs_v7 return type via new migration. - Only attach the global mousemove listener when the banner is actually visible (via watch on showBanner). Previously it fired at ~60 Hz for all dashboard users including non-trial/paying users. - Add a reactive time tick (nowTick, updated every 60s) so isAccountOldEnough re-evaluates during long sessions without requiring a page reload. --- src/components/dashboard/TrialBanner.vue | 34 +- src/types/supabase.types.ts | 2 + .../_backend/utils/supabase.types.ts | 2 + ...24120000_add_created_at_to_get_orgs_v7.sql | 326 ++++++++++++++++++ 4 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql diff --git a/src/components/dashboard/TrialBanner.vue b/src/components/dashboard/TrialBanner.vue index 92b7149e6f..999d24a690 100644 --- a/src/components/dashboard/TrialBanner.vue +++ b/src/components/dashboard/TrialBanner.vue @@ -8,12 +8,12 @@ * * Visibility conditions: * - User is on trial (not paying, trial_left > 0) - * - Account is 3+ hours old (based on subscription_start) + * - Account is 3+ hours old (based on org created_at) * - Organization has at least 1 app */ import type { ComponentPublicInstance } from 'vue' -import { computed, onMounted, onUnmounted, ref } from 'vue' +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useOrganizationStore } from '~/stores/organization' @@ -30,6 +30,11 @@ const rightPupil = ref({ x: 0, y: 0 }) const currentOrg = computed(() => organizationStore.currentOrganization) +// Reactive time tick so the 3-hour age check re-evaluates without needing a page reload. +// Updates every 60s — plenty for a 3-hour threshold. +const nowTick = ref(Date.now()) +let tickInterval: ReturnType | null = null + const isTrial = computed(() => { const org = currentOrg.value if (!org) @@ -39,11 +44,11 @@ const isTrial = computed(() => { const isAccountOldEnough = computed(() => { const org = currentOrg.value - if (!org?.subscription_start) + if (!org?.created_at) return false - const subscriptionStart = new Date(org.subscription_start) - const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000) - return subscriptionStart < threeHoursAgo + const createdAt = new Date(org.created_at) + const threeHoursAgo = new Date(nowTick.value - 3 * 60 * 60 * 1000) + return createdAt < threeHoursAgo }) const hasApps = computed(() => { @@ -90,6 +95,8 @@ function distToRect(x: number, y: number, rect: DOMRect): number { } function handleMouseMove(e: MouseEvent) { + if (!showBanner.value) + return leftPupil.value = calcOffset(leftEye.value, e) rightPupil.value = calcOffset(rightEye.value, e) if (ctaRef.value) { @@ -99,12 +106,25 @@ function handleMouseMove(e: MouseEvent) { } } +// Only attach the mousemove listener when the banner is actually visible. +// This avoids per-mousemove reactive work for non-trial / paying users. +watch(showBanner, (visible) => { + if (visible) + window.addEventListener('mousemove', handleMouseMove) + else + window.removeEventListener('mousemove', handleMouseMove) +}, { immediate: true }) + onMounted(() => { - window.addEventListener('mousemove', handleMouseMove) + tickInterval = setInterval(() => { + nowTick.value = Date.now() + }, 60_000) }) onUnmounted(() => { window.removeEventListener('mousemove', handleMouseMove) + if (tickInterval) + clearInterval(tickInterval) }) diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 020bedf1a3..b26b705db2 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -3204,6 +3204,7 @@ export type Database = { "2fa_has_access": boolean app_count: number can_use_more: boolean + created_at: string created_by: string credit_available: number credit_next_expiration: string @@ -3238,6 +3239,7 @@ export type Database = { "2fa_has_access": boolean app_count: number can_use_more: boolean + created_at: string created_by: string credit_available: number credit_next_expiration: string diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 020bedf1a3..b26b705db2 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -3204,6 +3204,7 @@ export type Database = { "2fa_has_access": boolean app_count: number can_use_more: boolean + created_at: string created_by: string credit_available: number credit_next_expiration: string @@ -3238,6 +3239,7 @@ export type Database = { "2fa_has_access": boolean app_count: number can_use_more: boolean + created_at: string created_by: string credit_available: number credit_next_expiration: string diff --git a/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql b/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql new file mode 100644 index 0000000000..c49794e81b --- /dev/null +++ b/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql @@ -0,0 +1,326 @@ +-- Add org created_at to get_orgs_v7 return type +-- The frontend TrialBanner needs the real org creation time to gate display. +-- Previously it used subscription_start which is the billing-cycle anchor (bc.cycle_start), +-- NOT the account creation time, causing the 3-hour check to pass immediately for new trial orgs. + +-- Drop both overloads of get_orgs_v7 (with and without parameters) +DROP FUNCTION IF EXISTS public.get_orgs_v7(); +DROP FUNCTION IF EXISTS public.get_orgs_v7(uuid); + +-- Recreate get_orgs_v7(userid) with created_at added to the return type. +-- Based on prod.sql (the canonical schema) — only change is the new created_at column. +CREATE FUNCTION public.get_orgs_v7(userid uuid) +RETURNS TABLE ( + gid uuid, + created_by uuid, + created_at timestamptz, + logo text, + name text, + role character varying, + paying boolean, + trial_left integer, + can_use_more boolean, + is_canceled boolean, + app_count bigint, + subscription_start timestamptz, + subscription_end timestamptz, + management_email text, + is_yearly boolean, + stats_updated_at timestamp without time zone, + next_stats_update_at timestamptz, + credit_available numeric, + credit_total numeric, + credit_next_expiration timestamptz, + enforcing_2fa boolean, + "2fa_has_access" boolean, + enforce_hashed_api_keys boolean, + password_policy_config jsonb, + password_has_access boolean, + require_apikey_expiration boolean, + max_apikey_expiration_days integer, + enforce_encrypted_bundles boolean, + required_encryption_key character varying, + use_new_rbac boolean +) LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = '' AS $$ +BEGIN + RETURN QUERY + WITH app_counts AS ( + SELECT owner_org, COUNT(*) as cnt + FROM public.apps + GROUP BY owner_org + ), + rbac_roles AS ( + SELECT rb.org_id, r.name, r.priority_rank + FROM public.role_bindings rb + JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_type = public.rbac_principal_user() + AND rb.principal_id = userid + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + UNION ALL + SELECT rb.org_id, r.name, r.priority_rank + FROM public.role_bindings rb + JOIN public.group_members gm ON gm.group_id = rb.principal_id + JOIN public.roles r ON rb.role_id = r.id + WHERE rb.principal_type = public.rbac_principal_group() + AND gm.user_id = userid + AND rb.scope_type = public.rbac_scope_org() + AND rb.org_id IS NOT NULL + AND (rb.expires_at IS NULL OR rb.expires_at > now()) + ), + rbac_org_roles AS ( + SELECT org_id, (ARRAY_AGG(rbac_roles.name ORDER BY rbac_roles.priority_rank DESC))[1] AS role_name + FROM rbac_roles + GROUP BY org_id + ), + user_orgs AS ( + SELECT ou.org_id + FROM public.org_users ou + WHERE ou.user_id = userid + UNION + SELECT rbac_org_roles.org_id + FROM rbac_org_roles + ), + -- Compute next stats update info for all paying orgs at once + paying_orgs_ordered AS ( + SELECT + o.id, + ROW_NUMBER() OVER (ORDER BY o.id ASC) - 1 as preceding_count + FROM public.orgs o + JOIN public.stripe_info si ON o.customer_id = si.customer_id + WHERE ( + (si.status = 'succeeded' + AND (si.canceled_at IS NULL OR si.canceled_at > NOW()) + AND si.subscription_anchor_end > NOW()) + OR si.trial_at > NOW() + ) + ), + -- Calculate current billing cycle for each org + billing_cycles AS ( + SELECT + o.id AS org_id, + CASE + WHEN COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) + > NOW() - date_trunc('MONTH', NOW()) + THEN date_trunc('MONTH', NOW() - INTERVAL '1 MONTH') + + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) + ELSE date_trunc('MONTH', NOW()) + + COALESCE(si.subscription_anchor_start - date_trunc('MONTH', si.subscription_anchor_start), '0 DAYS'::INTERVAL) + END AS cycle_start + FROM public.orgs o + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + ), + -- Calculate 2FA access status for user/org combinations + two_fa_access AS ( + SELECT + o.id AS org_id, + o.enforcing_2fa, + CASE + WHEN o.enforcing_2fa = false THEN true + ELSE public.has_2fa_enabled(userid) + END AS "2fa_has_access", + (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa + FROM public.orgs o + JOIN user_orgs uo ON uo.org_id = o.id + ), + -- Calculate password policy access status for user/org combinations + password_policy_access AS ( + SELECT + o.id AS org_id, + o.password_policy_config, + public.user_meets_password_policy(userid, o.id) AS password_has_access, + NOT public.user_meets_password_policy(userid, o.id) AS should_redact_password + FROM public.orgs o + JOIN user_orgs uo ON uo.org_id = o.id + ) + SELECT + o.id AS gid, + o.created_by, + o.created_at, + o.logo, + o.name, + CASE + WHEN o.use_new_rbac AND ou.user_right::text LIKE 'invite_%' THEN ou.user_right::varchar + WHEN o.use_new_rbac THEN COALESCE(ror.role_name, ou.rbac_role_name, ou.user_right::varchar) + ELSE COALESCE(ou.user_right::varchar, ror.role_name) + END AS role, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false + ELSE (si.status = 'succeeded') + END AS paying, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 + ELSE GREATEST(COALESCE((si.trial_at::date - NOW()::date), 0), 0)::integer + END AS trial_left, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false + ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) + OR (si.trial_at::date - NOW()::date > 0) + OR COALESCE(ucb.available_credits, 0) > 0) + END AS can_use_more, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false + ELSE (si.status = 'canceled') + END AS is_canceled, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint + ELSE COALESCE(ac.cnt, 0) + END AS app_count, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz + ELSE bc.cycle_start + END AS subscription_start, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz + ELSE (bc.cycle_start + INTERVAL '1 MONTH') + END AS subscription_end, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::text + ELSE o.management_email + END AS management_email, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false + ELSE COALESCE(si.price_id = p.price_y_id, false) + END AS is_yearly, + o.stats_updated_at, + CASE + WHEN poo.id IS NOT NULL THEN + public.get_next_cron_time('0 3 * * *', NOW()) + make_interval(mins => poo.preceding_count::int * 4) + ELSE NULL + END AS next_stats_update_at, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric + ELSE COALESCE(ucb.available_credits, 0) + END AS credit_available, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::numeric + ELSE COALESCE(ucb.total_credits, 0) + END AS credit_total, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz + ELSE ucb.next_expiration + END AS credit_next_expiration, + tfa.enforcing_2fa, + tfa."2fa_has_access", + o.enforce_hashed_api_keys, + ppa.password_policy_config, + ppa.password_has_access, + o.require_apikey_expiration, + o.max_apikey_expiration_days, + o.enforce_encrypted_bundles, + o.required_encryption_key, + o.use_new_rbac + FROM public.orgs o + JOIN user_orgs uo ON uo.org_id = o.id + LEFT JOIN public.org_users ou ON ou.user_id = userid AND o.id = ou.org_id + LEFT JOIN rbac_org_roles ror ON ror.org_id = o.id + LEFT JOIN two_fa_access tfa ON tfa.org_id = o.id + LEFT JOIN password_policy_access ppa ON ppa.org_id = o.id + LEFT JOIN public.stripe_info si ON o.customer_id = si.customer_id + LEFT JOIN public.plans p ON si.product_id = p.stripe_id + LEFT JOIN app_counts ac ON ac.owner_org = o.id + LEFT JOIN public.usage_credit_balances ucb ON ucb.org_id = o.id + LEFT JOIN paying_orgs_ordered poo ON poo.id = o.id + LEFT JOIN billing_cycles bc ON bc.org_id = o.id; +END; +$$; + +ALTER FUNCTION public.get_orgs_v7(uuid) OWNER TO "postgres"; + +-- Revoke from public roles (security: prevents users from querying other users' orgs) +REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM public; +REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM anon; +REVOKE ALL ON FUNCTION public.get_orgs_v7(uuid) FROM authenticated; + +-- Grant only to postgres and service_role (private function) +GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO postgres; +GRANT EXECUTE ON FUNCTION public.get_orgs_v7(uuid) TO service_role; + +-- Recreate the get_orgs_v7() wrapper with created_at in the return type +CREATE OR REPLACE FUNCTION public.get_orgs_v7() +RETURNS TABLE ( + gid uuid, + created_by uuid, + created_at timestamptz, + logo text, + name text, + role character varying, + paying boolean, + trial_left integer, + can_use_more boolean, + is_canceled boolean, + app_count bigint, + subscription_start timestamptz, + subscription_end timestamptz, + management_email text, + is_yearly boolean, + stats_updated_at timestamp without time zone, + next_stats_update_at timestamptz, + credit_available numeric, + credit_total numeric, + credit_next_expiration timestamptz, + enforcing_2fa boolean, + "2fa_has_access" boolean, + enforce_hashed_api_keys boolean, + password_policy_config jsonb, + password_has_access boolean, + require_apikey_expiration boolean, + max_apikey_expiration_days integer, + enforce_encrypted_bundles boolean, + required_encryption_key character varying, + use_new_rbac boolean +) LANGUAGE plpgsql +SET search_path = '' SECURITY DEFINER AS $$ +DECLARE + api_key_text text; + api_key record; + user_id uuid; +BEGIN + SELECT public.get_apikey_header() INTO api_key_text; + user_id := NULL; + + IF api_key_text IS NOT NULL THEN + SELECT * FROM public.find_apikey_by_value(api_key_text) INTO api_key; + + IF api_key IS NULL THEN + PERFORM public.pg_log('deny: INVALID_API_KEY', jsonb_build_object('source', 'header')); + RAISE EXCEPTION 'Invalid API key provided'; + END IF; + + -- Check if API key is expired + IF public.is_apikey_expired(api_key.expires_at) THEN + PERFORM public.pg_log('deny: API_KEY_EXPIRED', jsonb_build_object('key_id', api_key.id)); + RAISE EXCEPTION 'API key has expired'; + END IF; + + user_id := api_key.user_id; + + IF COALESCE(array_length(api_key.limited_to_orgs, 1), 0) > 0 THEN + RETURN QUERY + SELECT orgs.* + FROM public.get_orgs_v7(user_id) AS orgs + WHERE orgs.gid = ANY(api_key.limited_to_orgs::uuid[]); + RETURN; + END IF; + END IF; + + IF user_id IS NULL THEN + SELECT public.get_identity() INTO user_id; + + IF user_id IS NULL THEN + PERFORM public.pg_log('deny: UNAUTHENTICATED', '{}'::jsonb); + RAISE EXCEPTION 'No authentication provided - API key or valid session required'; + END IF; + END IF; + + RETURN QUERY SELECT * FROM public.get_orgs_v7(user_id); +END; +$$; + +ALTER FUNCTION public.get_orgs_v7() OWNER TO "postgres"; + +GRANT ALL ON FUNCTION public.get_orgs_v7() TO anon; +GRANT ALL ON FUNCTION public.get_orgs_v7() TO authenticated; +GRANT ALL ON FUNCTION public.get_orgs_v7() TO service_role; From bc48365316277600f0fac4ac05d8bf0ca43fd235 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 24 Feb 2026 09:03:47 +0100 Subject: [PATCH 4/7] fix(trial-banner): redact created_at for locked users, gate tick interval - Wrap created_at in the same 2FA/password-policy redaction CASE as all other sensitive fields in get_orgs_v7, returning NULL when the user lacks compliance. - Move the 60s nowTick interval into the showBanner watcher so it only runs when the banner is visible, consistent with the mousemove listener optimization. --- src/components/dashboard/TrialBanner.vue | 25 +++++++++++-------- ...24120000_add_created_at_to_get_orgs_v7.sql | 5 +++- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/components/dashboard/TrialBanner.vue b/src/components/dashboard/TrialBanner.vue index 999d24a690..88463ac81e 100644 --- a/src/components/dashboard/TrialBanner.vue +++ b/src/components/dashboard/TrialBanner.vue @@ -13,7 +13,7 @@ */ import type { ComponentPublicInstance } from 'vue' -import { computed, onMounted, onUnmounted, ref, watch } from 'vue' +import { computed, onUnmounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useOrganizationStore } from '~/stores/organization' @@ -106,21 +106,24 @@ function handleMouseMove(e: MouseEvent) { } } -// Only attach the mousemove listener when the banner is actually visible. -// This avoids per-mousemove reactive work for non-trial / paying users. +// Only attach the mousemove listener and time-tick interval when the banner +// is actually visible. This avoids unnecessary work for non-trial / paying users. watch(showBanner, (visible) => { - if (visible) + if (visible) { window.addEventListener('mousemove', handleMouseMove) - else + tickInterval = setInterval(() => { + nowTick.value = Date.now() + }, 60_000) + } + else { window.removeEventListener('mousemove', handleMouseMove) + if (tickInterval) { + clearInterval(tickInterval) + tickInterval = null + } + } }, { immediate: true }) -onMounted(() => { - tickInterval = setInterval(() => { - nowTick.value = Date.now() - }, 60_000) -}) - onUnmounted(() => { window.removeEventListener('mousemove', handleMouseMove) if (tickInterval) diff --git a/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql b/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql index c49794e81b..6513b50728 100644 --- a/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql +++ b/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql @@ -138,7 +138,10 @@ BEGIN SELECT o.id AS gid, o.created_by, - o.created_at, + CASE + WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN NULL::timestamptz + ELSE o.created_at + END AS created_at, o.logo, o.name, CASE From 5413ba40a6bd6b10cc75478d118fd028dac94293 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 24 Feb 2026 09:08:29 +0100 Subject: [PATCH 5/7] fix: address CodeRabbit review comments - Rename MAX_TRAVEL/EXCITE_DISTANCE to camelCase (maxTravel/exciteDistance) - Add DaisyUI d-btn class to CTA button - Change get_orgs_v7(uuid) from STABLE to VOLATILE (calls VOLATILE helpers) - Add COALESCE wrappers to paying/can_use_more/is_canceled for NULL safety --- src/components/dashboard/TrialBanner.vue | 10 +++++----- .../20260224120000_add_created_at_to_get_orgs_v7.sql | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/dashboard/TrialBanner.vue b/src/components/dashboard/TrialBanner.vue index 88463ac81e..a0971dd7d8 100644 --- a/src/components/dashboard/TrialBanner.vue +++ b/src/components/dashboard/TrialBanner.vue @@ -60,7 +60,7 @@ const showBanner = computed(() => { return isTrial.value && isAccountOldEnough.value && hasApps.value }) -const MAX_TRAVEL = 4 // How far the pupil can move from center (px) +const maxTravel = 4 // How far the pupil can move from center (px) function calcOffset(eye: HTMLElement | null, ev: MouseEvent) { if (!eye) @@ -78,7 +78,7 @@ function calcOffset(eye: HTMLElement | null, ev: MouseEvent) { return { x: 0, y: 0 } // Easing factor so they don't jump to the edge instantly - const easedDist = Math.min(dist * 0.1, MAX_TRAVEL) + const easedDist = Math.min(dist * 0.1, maxTravel) return { x: (dx / dist) * easedDist, @@ -86,7 +86,7 @@ function calcOffset(eye: HTMLElement | null, ev: MouseEvent) { } } -const EXCITE_DISTANCE = 80 // px from CTA edge to trigger excitement +const exciteDistance = 80 // px from CTA edge to trigger excitement function distToRect(x: number, y: number, rect: DOMRect): number { const dx = Math.max(rect.left - x, 0, x - rect.right) @@ -102,7 +102,7 @@ function handleMouseMove(e: MouseEvent) { if (ctaRef.value) { const el = ctaRef.value.$el ?? ctaRef.value const ctaRect = (el as HTMLElement).getBoundingClientRect() - excited.value = distToRect(e.clientX, e.clientY, ctaRect) < EXCITE_DISTANCE + excited.value = distToRect(e.clientX, e.clientY, ctaRect) < exciteDistance } } @@ -163,7 +163,7 @@ onUnmounted(() => { {{ t('trial-banner-cta') }} diff --git a/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql b/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql index 6513b50728..e1c3c152cf 100644 --- a/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql +++ b/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql @@ -41,7 +41,7 @@ RETURNS TABLE ( enforce_encrypted_bundles boolean, required_encryption_key character varying, use_new_rbac boolean -) LANGUAGE plpgsql STABLE SECURITY DEFINER +) LANGUAGE plpgsql VOLATILE SECURITY DEFINER SET search_path = '' AS $$ BEGIN RETURN QUERY @@ -151,7 +151,7 @@ BEGIN END AS role, CASE WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'succeeded') + ELSE COALESCE(si.status = 'succeeded', false) END AS paying, CASE WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0 @@ -159,13 +159,13 @@ BEGIN END AS trial_left, CASE WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE ((si.status = 'succeeded' AND si.is_good_plan = true) + ELSE COALESCE((si.status = 'succeeded' AND si.is_good_plan = true) OR (si.trial_at::date - NOW()::date > 0) - OR COALESCE(ucb.available_credits, 0) > 0) + OR COALESCE(ucb.available_credits, 0) > 0, false) END AS can_use_more, CASE WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN false - ELSE (si.status = 'canceled') + ELSE COALESCE(si.status = 'canceled', false) END AS is_canceled, CASE WHEN tfa.should_redact_2fa OR ppa.should_redact_password THEN 0::bigint From 30f6d308682f6185b761ed524c8902e92b978619 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 24 Feb 2026 09:13:00 +0100 Subject: [PATCH 6/7] fix: drop explicit volatility marker from get_orgs_v7 --- .../migrations/20260224120000_add_created_at_to_get_orgs_v7.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql b/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql index e1c3c152cf..1a2a0424a5 100644 --- a/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql +++ b/supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql @@ -41,7 +41,7 @@ RETURNS TABLE ( enforce_encrypted_bundles boolean, required_encryption_key character varying, use_new_rbac boolean -) LANGUAGE plpgsql VOLATILE SECURITY DEFINER +) LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN RETURN QUERY From b94a488b5cab1f6c2870332d3a3d6299123c74c3 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 24 Feb 2026 13:07:16 +0100 Subject: [PATCH 7/7] i18n: add trial banner translations for all languages Translate trial-banner-cta and trial-banner-message keys to de, es, fr, hi, id, it, ja, ko, pl, pt-br, ru, tr, vi, zh-cn using DeepL API. --- messages/de.json | 2 ++ messages/es.json | 2 ++ messages/fr.json | 2 ++ messages/hi.json | 2 ++ messages/id.json | 2 ++ messages/it.json | 2 ++ messages/ja.json | 2 ++ messages/ko.json | 2 ++ messages/pl.json | 2 ++ messages/pt-br.json | 2 ++ messages/ru.json | 2 ++ messages/tr.json | 2 ++ messages/vi.json | 2 ++ messages/zh-cn.json | 2 ++ 14 files changed, 28 insertions(+) diff --git a/messages/de.json b/messages/de.json index ec7290fd26..fe5cba283d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Übertragung der App-Besitzrechte", "transfer-app-ownership-requirements": "Um eine App zwischen Organisationen zu übertragen, müssen Sie in beiden Quell- und Zielorganisationen Super-Admin-Berechtigungen haben. Dies gewährleistet eine sichere Übertragung des Eigentums und verhindert unbefugten Zugriff.", "transfer-app-ownership-too-soon": "Sie können Apps nur alle 32 Tage übertragen.", + "trial-banner-cta": "Pläne ansehen", + "trial-banner-message": "Gefällt Ihnen Ihre Capgo-Testversion? Abonnieren Sie einen Plan.", "trial-end-date": "Enddatum der Testphase", "trial-left": "verbleibende Tage", "trial-organizations-list": "Liste der Test-Organisationen", diff --git a/messages/es.json b/messages/es.json index 818e811a02..d79a31e0d7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Transferir la propiedad de la aplicación", "transfer-app-ownership-requirements": "Para transferir una aplicación entre organizaciones, debes tener privilegios de super_administrador tanto en la organización de origen como en la de destino. Esto garantiza una transferencia segura de propiedad y evita el acceso no autorizado.", "transfer-app-ownership-too-soon": "Solo puedes transferir aplicaciones cada 32 días.", + "trial-banner-cta": "Ver planes", + "trial-banner-message": "¿Disfrutas de la prueba de Capgo? Suscríbete a un plan.", "trial-end-date": "Fecha de fin de prueba", "trial-left": "días restantes", "trial-organizations-list": "Lista de organizaciones en prueba", diff --git a/messages/fr.json b/messages/fr.json index 32817220b3..fbbbed4dbb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Transférer la propriété de l'application", "transfer-app-ownership-requirements": "Pour transférer une application entre organisations, vous devez avoir des privilèges de super_admin dans les deux organisations source et destination. Cela garantit un transfert de propriété sécurisé et empêche l'accès non autorisé.", "transfer-app-ownership-too-soon": "Vous ne pouvez transférer des applications que tous les 32 jours", + "trial-banner-cta": "Voir les plans", + "trial-banner-message": "Vous appréciez la période d'essai de Capgo ? Souscrivez à un plan.", "trial-end-date": "Date de fin d'essai", "trial-left": "jours restants", "trial-organizations-list": "Liste des organisations en essai", diff --git a/messages/hi.json b/messages/hi.json index e9d5d93574..04bbd82aff 100644 --- a/messages/hi.json +++ b/messages/hi.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "ऐप मालिकाना हस्तांतरण करें", "transfer-app-ownership-requirements": "एक ऐप को संगठनों के बीच स्थानांतरित करने के लिए, आपको स्रोत और गंतव्य संगठनों में super_admin विशेषाधिकार होना चाहिए। यह मालिकाना हक के सुरक्षित स्थानांतरण की सुनिश्चिति करता है और अनधिकृत पहुंच को रोकता है।", "transfer-app-ownership-too-soon": "आप केवल हर 32 दिनों में ऐप्स स्थानांतरित कर सकते हैं।", + "trial-banner-cta": "योजनाएँ देखें", + "trial-banner-message": "क्या आप अपने Capgo ट्रायल का आनंद ले रहे हैं? एक प्लान सब्सक्राइब करें।", "trial-end-date": "ट्रायल समाप्ति तिथि", "trial-left": "बचे हुए दिन", "trial-organizations-list": "ट्रायल संगठनों की सूची", diff --git a/messages/id.json b/messages/id.json index 153562b9c3..ba96e8c49d 100644 --- a/messages/id.json +++ b/messages/id.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Transfer kepemilikan aplikasi", "transfer-app-ownership-requirements": "Untuk mentransfer aplikasi antara organisasi, Anda harus memiliki hak istimewa super_admin di kedua organisasi sumber dan tujuan. Ini memastikan transfer kepemilikan yang aman dan mencegah akses tidak sah.", "transfer-app-ownership-too-soon": "Anda hanya dapat mentransfer aplikasi setiap 32 hari", + "trial-banner-cta": "Lihat paket", + "trial-banner-message": "Menikmati uji coba Capgo Anda? Berlangganan ke sebuah paket.", "trial-end-date": "Tanggal akhir uji coba", "trial-left": "sisa hari", "trial-organizations-list": "Daftar organisasi uji coba", diff --git a/messages/it.json b/messages/it.json index ba8c732c71..e3e2f9545c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Trasferisci la proprietà dell'app", "transfer-app-ownership-requirements": "Per trasferire un'app tra organizzazioni, devi avere privilegi di super_admin sia nell'organizzazione di origine che in quella di destinazione. Questo garantisce un trasferimento sicuro della proprietà e previene l'accesso non autorizzato.", "transfer-app-ownership-too-soon": "Puoi trasferire le app solo ogni 32 giorni", + "trial-banner-cta": "Visualizza i piani", + "trial-banner-message": "Vi piace la prova di Capgo? Abbonatevi a un piano.", "trial-end-date": "Data fine prova", "trial-left": "giorni rimanenti", "trial-organizations-list": "Elenco organizzazioni in prova", diff --git a/messages/ja.json b/messages/ja.json index 27842f9dc8..4da85b38a4 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "アプリの所有権を譲渡する", "transfer-app-ownership-requirements": "アプリを組織間で転送するには、ソースと宛先の両方の組織でsuper_admin権限を持っている必要があります。これにより、所有権の安全な転送が保証され、不正なアクセスが防止されます。", "transfer-app-ownership-too-soon": "アプリの転送は32日ごとに1回のみ可能です", + "trial-banner-cta": "プランを見る", + "trial-banner-message": "Capgoのトライアルをお楽しみですか?プランにご加入ください。", "trial-end-date": "トライアル終了日", "trial-left": "残りの日数", "trial-organizations-list": "トライアル組織一覧", diff --git a/messages/ko.json b/messages/ko.json index 110fd33402..86d4df8948 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "앱 소유권 이전", "transfer-app-ownership-requirements": "앱을 조직 간에 전송하려면 소스 및 대상 조직에서 모두 super_admin 권한이 있어야 합니다. 이는 소유권의 안전한 전송을 보장하고 무단 접근을 방지합니다.", "transfer-app-ownership-too-soon": "앱을 32일마다 한 번만 이전할 수 있습니다.", + "trial-banner-cta": "요금제 보기", + "trial-banner-message": "Capgo 체험을 즐기고 계신가요? 요금제를 구독하세요.", "trial-end-date": "체험 종료일", "trial-left": "남은 날들", "trial-organizations-list": "체험 조직 목록", diff --git a/messages/pl.json b/messages/pl.json index 526a94fe51..a9a0e7df23 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Przenieś własność aplikacji", "transfer-app-ownership-requirements": "Aby przenieść aplikację między organizacjami, musisz mieć uprawnienia super_admina zarówno w organizacji źródłowej, jak i docelowej. Zapewnia to bezpieczne przeniesienie własności i zapobiega nieautoryzowanemu dostępowi.", "transfer-app-ownership-too-soon": "Możesz przenosić aplikacje tylko co 32 dni.", + "trial-banner-cta": "Wyświetl plany", + "trial-banner-message": "Korzystasz z okresu próbnego Capgo? Subskrybuj plan.", "trial-end-date": "Data zakończenia okresu próbnego", "trial-left": "pozostałe dni", "trial-organizations-list": "Lista organizacji w okresie próbnym", diff --git a/messages/pt-br.json b/messages/pt-br.json index 93c62594be..805a8fb791 100644 --- a/messages/pt-br.json +++ b/messages/pt-br.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Transferir propriedade do aplicativo", "transfer-app-ownership-requirements": "Para transferir um aplicativo entre organizações, você deve ter privilégios de super_administrador em ambas as organizações de origem e destino. Isso garante a transferência segura da propriedade e evita o acesso não autorizado.", "transfer-app-ownership-too-soon": "Você só pode transferir aplicativos a cada 32 dias.", + "trial-banner-cta": "Exibir planos", + "trial-banner-message": "Está gostando do teste do Capgo? Assine um plano.", "trial-end-date": "Data de término do teste", "trial-left": "dias restantes", "trial-organizations-list": "Lista de organizações em teste", diff --git a/messages/ru.json b/messages/ru.json index 2ada311f9f..530890936d 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Передача владения приложением", "transfer-app-ownership-requirements": "Для передачи приложения между организациями вы должны иметь привилегии супер_администратора как в исходной, так и в целевой организации. Это обеспечивает безопасную передачу прав собственности и предотвращает несанкционированный доступ.", "transfer-app-ownership-too-soon": "Вы можете переносить приложения только каждые 32 дня.", + "trial-banner-cta": "Просмотр планов", + "trial-banner-message": "Нравится пробная версия Capgo? Подпишитесь на тарифный план.", "trial-end-date": "Дата окончания пробного периода", "trial-left": "осталось дней", "trial-organizations-list": "Список организаций на пробном периоде", diff --git a/messages/tr.json b/messages/tr.json index 37c75af590..b76a536b68 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Uygulama sahipliğini aktar", "transfer-app-ownership-requirements": "Bir uygulamayı organizasyonlar arasında aktarmak için, hem kaynak hem de hedef organizasyonlarda super_admin ayrıcalıklarına sahip olmanız gerekir. Bu, mülkiyetin güvenli bir şekilde aktarılmasını sağlar ve yetkisiz erişimi önler.", "transfer-app-ownership-too-soon": "Yalnızca her 32 günde bir uygulamaları aktarabilirsiniz.", + "trial-banner-cta": "Planları görüntüle", + "trial-banner-message": "Capgo deneme sürümünüzü beğendiniz mi? Bir plana abone olun.", "trial-end-date": "Deneme Bitiş Tarihi", "trial-left": "kalan günler", "trial-organizations-list": "Deneme Organizasyonları Listesi", diff --git a/messages/vi.json b/messages/vi.json index df7f3153ef..79074be8ea 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "Chuyển quyền sở hữu ứng dụng", "transfer-app-ownership-requirements": "Để chuyển một ứng dụng giữa các tổ chức, bạn phải có quyền super_admin trong cả tổ chức nguồn và đích. Điều này đảm bảo việc chuyển giao sở hữu an toàn và ngăn chặn truy cập không được phép.", "transfer-app-ownership-too-soon": "Bạn chỉ có thể chuyển ứng dụng mỗi 32 ngày", + "trial-banner-cta": "Xem các gói", + "trial-banner-message": "Bạn đang trải nghiệm bản dùng thử Capgo? Hãy đăng ký gói dịch vụ.", "trial-end-date": "Ngày kết thúc dùng thử", "trial-left": "còn lại các ngày", "trial-organizations-list": "Danh sách tổ chức dùng thử", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 5f0e117da7..b630647b86 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -1393,6 +1393,8 @@ "transfer-app-ownership": "转让应用程序所有权", "transfer-app-ownership-requirements": "要在组织间传输应用程序,您必须在源组织和目标组织中都拥有 super_admin 权限。这可确保所有权的安全转移,并防止未经授权的访问。", "transfer-app-ownership-too-soon": "每 32 天只能传输一次应用程序", + "trial-banner-cta": "查看计划", + "trial-banner-message": "享受您的 Capgo 试用版?订阅计划。", "trial-end-date": "试用结束日期", "trial-left": "剩余天数", "trial-organizations-list": "试用组织列表",