Skip to content

Comments

feat(frontend): add trial banner with eye-tracking and sparkle CTA#1663

Merged
riderx merged 9 commits intomainfrom
feat/trial-banner-eyes-sparkle
Feb 24, 2026
Merged

feat(frontend): add trial banner with eye-tracking and sparkle CTA#1663
riderx merged 9 commits intomainfrom
feat/trial-banner-eyes-sparkle

Conversation

@WcaleNieWolny
Copy link
Contributor

@WcaleNieWolny WcaleNieWolny commented Feb 23, 2026

Summary

  • New TrialBanner component with 👀-style eyes that follow the user's cursor in real-time
  • Pupil excitement effect — pupils scale up 1.4× when cursor approaches the "View Plans" CTA button
  • Always-on sparkle & shimmer — floating ✦ particles and glowing box-shadow on the CTA button to encourage clicks
  • i18n support — added trial-banner-message and trial-banner-cta keys
  • Dashboard integration — banner mounted above dashboard content for trial users
  • Plans page cleanup — hide "Expert as a Service" CTA for non-paying (trial) orgs

Details

TrialBanner Component (src/components/dashboard/TrialBanner.vue)

  • Pure CSS div-based eyes (no SVG) for cross-browser compatibility
  • Vertically oval white eyes (20×25px) with dark pupils (9×9px) and white highlight glint
  • Mouse tracking via mousemove listener with requestAnimationFrame throttling
  • Pupil scale baked into inline transform string to avoid CSS specificity issues
  • 6 sparkle particles with staggered sparkle-float animations
  • Shimmer pseudo-element on CTA button
  • Visibility gated on trial subscription status
  • Dark mode support via scoped .dark prefix styles
  • aria-hidden="true" on decorative eye elements

Other Changes

  • dashboard.vue: Mount <TrialBanner /> in the dashboard layout
  • messages/en.json: Add trial banner i18n strings
  • components.d.ts: Auto-generated component registration
  • Plans.vue: Conditionally hide Expert-as-a-Service CTA for non-paying orgs

Verification

  • bun lint:fix
  • bun typecheck

Summary by CodeRabbit

  • New Features

    • Dashboard trial banner with animated “googly” eyes, sparkles, and a “View plans” CTA; supports dark mode and responsive layouts.
    • English localization added for the banner message and CTA.
  • UI Changes

    • Banner appears on the dashboard for eligible trial accounts (account age threshold, trial status, and at least one app).

- 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
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 23, 2026

Warning

Rate limit exceeded

@WcaleNieWolny has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 15 minutes and 28 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 3ab393d and b94a488.

📒 Files selected for processing (16)
  • messages/de.json
  • messages/es.json
  • messages/fr.json
  • messages/hi.json
  • messages/id.json
  • messages/it.json
  • messages/ja.json
  • messages/ko.json
  • messages/pl.json
  • messages/pt-br.json
  • messages/ru.json
  • messages/tr.json
  • messages/vi.json
  • messages/zh-cn.json
  • src/components/dashboard/TrialBanner.vue
  • supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql
📝 Walkthrough

Walkthrough

Adds a new TrialBanner Vue component and global registration, two English translation keys, non-nullable created_at in apikeys types, and a rewritten get_orgs_v7 DB function (with richer return fields, CTEs, redaction, and tightened access).

Changes

Cohort / File(s) Summary
Localization
messages/en.json
Added two translation keys: trial-banner-cta and trial-banner-message.
Frontend components & registration
src/components/dashboard/TrialBanner.vue, src/components.d.ts, src/pages/dashboard.vue
New Vue 3 SFC TrialBanner (TypeScript script-setup) added and globally declared; rendered on dashboard. Implements cursor-tracking "googly eyes", CTA sparkles, visibility logic (trial status, account age tick, app count), and conditional mousemove listener.
Supabase client & function types
src/types/supabase.types.ts, supabase/functions/_backend/utils/supabase.types.ts
Made apikeys.Row.created_at non-nullable (string) in client and backend function type defs.
Database migration / function
supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql
Replaced/extended get_orgs_v7 with a new get_orgs_v7(userid uuid) returning enriched org metadata including created_at; added CTEs for billing/roles/app counts, redaction logic, ownership/security changes, and a 0-arg wrapper enforcing API-key or identity-based auth and adjusted grants.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client as Client (Dashboard)
participant Wrapper as public.get_orgs_v7() (wrapper)
participant Func as public.get_orgs_v7(user_id)
participant DB as Database / Stripe / CTEs
Client->>Wrapper: Request get_orgs_v7()
Wrapper->>Wrapper: read API key header
alt valid API key
Wrapper->>DB: validate API key, map to user_id / scoped orgs
else no API key / identity
Wrapper->>Wrapper: check authenticated identity
end
Wrapper->>Func: call get_orgs_v7(user_id)
Func->>DB: run CTEs, joins, compute org metadata (created_at, trial_left, app_count, etc.)
DB-->>Func: return rows
Func-->>Wrapper: return result set (redacted as needed)
Wrapper-->>Client: respond with orgs

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • riderx

Poem

🐰 I hopped in bright with googly eyes,
I shimmer, sparkle, and advertise,
"View plans!" I cheer with playful clout,
I tick, I track — just hop right out,
A tiny nudge to help you scout. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers Summary, verification steps, and implementation details. However, the required Test plan and Screenshots sections are missing, and the Checklist is incomplete with no items marked. Add the Test plan section with reproduction steps, include Screenshots/videos of the TrialBanner in action, and complete the Checklist by marking applicable items with [x].
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature: adding a TrialBanner component with eye-tracking animation and sparkle CTA, which aligns with the primary changes across multiple files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/trial-banner-eyes-sparkle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1cf1d89617

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/components/dashboard/TrialBanner.vue (3)

92-100: Missing requestAnimationFrame throttling — claimed in the PR description but not implemented.

The mousemove event can fire at 200+ Hz on high-refresh displays. Each call triggers two Vue reactive ref assignments and, when the banner is visible, a getBoundingClientRect() read (forces a layout flush). RAF throttling would cap updates to one per animation frame:

♻️ Suggested RAF throttle
+let rafId: number | null = null
+let pendingEvent: MouseEvent | null = null
+
 function handleMouseMove(e: MouseEvent) {
+  if (!showBanner.value) return
+  pendingEvent = e
+  if (rafId !== null) return
+  rafId = requestAnimationFrame(() => {
+    rafId = null
+    if (!pendingEvent) return
+    const ev = pendingEvent
+    pendingEvent = null
-  leftPupil.value = calcOffset(leftEye.value, e)
-  rightPupil.value = calcOffset(rightEye.value, e)
+    leftPupil.value = calcOffset(leftEye.value, ev)
+    rightPupil.value = calcOffset(rightEye.value, ev)
     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
+      const ctaRect = (el as HTMLElement).getBoundingClientRect()
+      excited.value = distToRect(ev.clientX, ev.clientY, ctaRect) < EXCITE_DISTANCE
     }
+  })
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dashboard/TrialBanner.vue` around lines 92 - 100,
handleMouseMove currently runs on every mousemove and does two reactive
assignments plus a getBoundingClientRect, causing layout thrashing; implement
RAF throttling by queuing the latest MouseEvent and scheduling a single
requestAnimationFrame if one isn’t pending, then in the RAF callback call
calcOffset for leftEye/rightEye, set leftPupil/rightPupil, and compute the
ctaRect/getBoundingClientRect and set excited based on distToRect and
EXCITE_DISTANCE; use a module-scoped rafId (or boolean rafPending) and a
latestEvent variable so handleMouseMove just updates latestEvent and schedules
RAF.

227-265: CTA button uses fully custom scoped CSS rather than a DaisyUI base.

The interactive CTA is rendered as a <router-link> styled entirely with scoped .cta-button / .cta-sparkle classes. The complex shimmer/sparkle animations do require custom CSS, but the structural button styles (padding, border-radius, font weight, colours) could be composed from a d-btn base, keeping animation overrides in scoped styles. As per coding guidelines, DaisyUI components should be used for interactive elements in Vue components.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dashboard/TrialBanner.vue` around lines 227 - 265, Replace the
fully custom structural styling on the CTA with DaisyUI base classes: add the
d-btn (and variant like d-btn-primary) to the <router-link> that currently uses
the .cta-button class, remove structural rules from .cta-button (padding,
border-radius, font-size, font-weight, background-color, color, box-shadow) so
those come from DaisyUI, and keep only the scoped animation/visual overrides
(the shimmer/sparkle rules for .cta-sparkle and any
transform/overflow/transition tweaks) in TrialBanner.vue; ensure hover/focus
tweaks are implemented as overrides that target .cta-button.d-btn and preserve
the dark .cta-button:focus override logic.

95-99: $el ?? ctaRef.value fallback is type-unsafe dead code.

$el is always set on a mounted component instance, so the nullish-coalescing fallback ?? ctaRef.value is never reached. If it were, passing a ComponentPublicInstance as an HTMLElement would still throw at runtime. The cast is safe to simplify:

🔧 Proposed cleanup
-    const el = ctaRef.value.$el ?? ctaRef.value
-    const ctaRect = (el as HTMLElement).getBoundingClientRect()
+    const ctaRect = (ctaRef.value.$el as HTMLElement).getBoundingClientRect()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dashboard/TrialBanner.vue` around lines 95 - 99, The current
nullish-coalescing fallback "$el ?? ctaRef.value" is dead/unsafe; replace it by
using the mounted component's element directly and a proper HTMLElement cast:
grab ctaRef.value.$el and cast it to HTMLElement (e.g., treat it as HTMLElement)
before calling getBoundingClientRect, remove the ?? ctaRef.value fallback and
the broad cast on el, and keep the existing logic using distToRect,
EXCITE_DISTANCE and excited so the computed distance uses the actual HTMLElement
returned from the component instance.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/dashboard/TrialBanner.vue`:
- Around line 102-108: The component currently adds a global window mousemove
listener unconditionally in onMounted and removes it in onUnmounted which causes
unnecessary work for non-trial users; update the mount/unmount logic so the
listener is only added when the banner is actually shown (check the reactive
showBanner) and removed when it becomes hidden, and also add an early-return at
the start of handleMouseMove if leftEye/rightEye/ctaRef are null to avoid
allocations; ensure you reference the existing functions/refs (onMounted,
onUnmounted, handleMouseMove, showBanner, leftEye, rightEye, ctaRef) and keep
cleanup logic to remove the listener whenever showBanner toggles or the
component unmounts.
- Around line 40-47: The computed isAccountOldEnough currently only recalculates
when currentOrg changes, so Date.now() is frozen for the session; change the
logic so age is evaluated continuously by introducing a reactive time tick
(e.g., a ref updated on an interval in onMounted and cleared in onUnmounted) and
reference that tick inside isAccountOldEnough so the computed re-evaluates as
time passes; also explicitly handle org.subscription_start === null (don’t treat
it the same as undefined) by returning a distinct result or boolean (e.g., false
or an “unknown” flag) so the UI can distinguish null subscription_start cases
from “too new” accounts—update references to isAccountOldEnough, currentOrg and
subscription_start accordingly.

---

Nitpick comments:
In `@src/components/dashboard/TrialBanner.vue`:
- Around line 92-100: handleMouseMove currently runs on every mousemove and does
two reactive assignments plus a getBoundingClientRect, causing layout thrashing;
implement RAF throttling by queuing the latest MouseEvent and scheduling a
single requestAnimationFrame if one isn’t pending, then in the RAF callback call
calcOffset for leftEye/rightEye, set leftPupil/rightPupil, and compute the
ctaRect/getBoundingClientRect and set excited based on distToRect and
EXCITE_DISTANCE; use a module-scoped rafId (or boolean rafPending) and a
latestEvent variable so handleMouseMove just updates latestEvent and schedules
RAF.
- Around line 227-265: Replace the fully custom structural styling on the CTA
with DaisyUI base classes: add the d-btn (and variant like d-btn-primary) to the
<router-link> that currently uses the .cta-button class, remove structural rules
from .cta-button (padding, border-radius, font-size, font-weight,
background-color, color, box-shadow) so those come from DaisyUI, and keep only
the scoped animation/visual overrides (the shimmer/sparkle rules for
.cta-sparkle and any transform/overflow/transition tweaks) in TrialBanner.vue;
ensure hover/focus tweaks are implemented as overrides that target
.cta-button.d-btn and preserve the dark .cta-button:focus override logic.
- Around line 95-99: The current nullish-coalescing fallback "$el ??
ctaRef.value" is dead/unsafe; replace it by using the mounted component's
element directly and a proper HTMLElement cast: grab ctaRef.value.$el and cast
it to HTMLElement (e.g., treat it as HTMLElement) before calling
getBoundingClientRect, remove the ?? ctaRef.value fallback and the broad cast on
el, and keep the existing logic using distToRect, EXCITE_DISTANCE and excited so
the computed distance uses the actual HTMLElement returned from the component
instance.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bc15845 and 1cf1d89.

📒 Files selected for processing (5)
  • messages/en.json
  • src/components.d.ts
  • src/components/dashboard/TrialBanner.vue
  • src/pages/dashboard.vue
  • src/pages/settings/organization/Plans.vue

riderx and others added 3 commits February 23, 2026 20:27
…ak & time reactivity

- 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.
@WcaleNieWolny WcaleNieWolny force-pushed the feat/trial-banner-eyes-sparkle branch from ccaa212 to 3ab393d Compare February 24, 2026 07:54
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
supabase/functions/_backend/utils/supabase.types.ts (1)

3201-3234: ⚠️ Potential issue | 🟡 Minor

Confirm created_at type safety in get_orgs_v7 function.

The orgs.created_at column is nullable (defined as timestamp with time zone DEFAULT "now" () with no NOT NULL constraint), but get_orgs_v7 declares it as non-nullable (created_at timestamptz). The function selects the column without a COALESCE, so if any org row has NULL, callers will receive null values despite the type declaring the field as always present. Either add a NOT NULL constraint to the column, coalesce in the SQL, or mark the type as nullable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_backend/utils/supabase.types.ts` around lines 3201 -
3234, The get_orgs_v7 return type declares created_at as non-nullable but the
orgs.created_at column is nullable; update the code to ensure runtime/type
alignment by either (A) marking created_at as nullable in the get_orgs_v7 return
type (i.e., allow string | null) so callers can handle null, or (B) change the
SQL that populates get_orgs_v7 to coalesce(created_at, '1970-01-01T00:00:00Z')
(or another safe default) before returning so the field is guaranteed non-null;
locate references to get_orgs_v7 and the created_at field in supabase.types.ts
and apply one of these two fixes consistently across the function signature and
its SQL selection.
src/types/supabase.types.ts (1)

3201-3234: ⚠️ Potential issue | 🟡 Minor

Make created_at nullable in TypeScript types to match the database column definition.

The created_at column in the orgs table is nullable (no NOT NULL constraint), but the function returns it unwrapped without null handling. The TypeScript types currently show created_at: string (non-nullable), which will mislead UI code if a null value is returned. Update the type to created_at: string | null to align with the database contract.

Type correction for both function overloads
-              created_at: string
+              created_at: string | null

Also applies to: 3237-3269

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/supabase.types.ts` around lines 3201 - 3234, The generated
TypeScript return types for the RPC that include the org record have the
created_at field typed as non-nullable string; update the Returns object(s) in
src/types/supabase.types.ts so that the created_at property is typed as string |
null (apply this change to both overloaded Returns blocks that define the org
shape) to match the database column; locate the Returns {...} arrays that
include "created_at" and replace its type accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/dashboard/TrialBanner.vue`:
- Around line 131-167: The CTA router-link in TrialBanner.vue (ref="ctaRef",
currently class="cta-button cta-sparkle") is missing the DaisyUI base button
class; update the router-link's class list to include the d-btn base (and any
appropriate variant like d-btn-primary or sizing classes as needed) so the CTA
uses DaisyUI button styling while preserving existing sparkle classes (e.g., add
d-btn alongside cta-button and cta-sparkle).
- Around line 63-107: The constants MAX_TRAVEL and EXCITE_DISTANCE should be
renamed to camelCase (e.g., maxTravel and exciteDistance) and all references
updated; update the definition of MAX_TRAVEL used in calcOffset to maxTravel and
update EXCITE_DISTANCE used in handleMouseMove (and the comparison with
distToRect) to exciteDistance, ensuring calcOffset, handleMouseMove and any
other function (distToRect usage) reference the new names consistently.

In `@supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql`:
- Around line 115-130: The boolean result columns paying, can_use_more, and
is_canceled can become NULL when the LEFT JOINed stripe_info (si) is absent;
update each CASE (the ones producing aliases paying, can_use_more, is_canceled)
to wrap their ELSE boolean expression with COALESCE(..., false) (or wrap the
whole CASE result in COALESCE(..., false)) so that when si is NULL these columns
return false instead of NULL; reference the CASEs that inspect si.status,
si.is_good_plan, si.trial_at and the redaction flags tfa.should_redact_2fa /
ppa.should_redact_password to locate the exact expressions to change.
- Line 42: The get_orgs_v7(uuid) function is declared STABLE but calls VOLATILE
helpers (has_2fa_enabled, user_meets_password_policy, get_next_cron_time);
update the function declaration in get_orgs_v7 to use VOLATILE instead of STABLE
so the stability contract matches the callees and avoids runtime/cache errors.
Locate the function header containing "get_orgs_v7(uuid) ... STABLE SECURITY
DEFINER" and replace STABLE with VOLATILE, leaving the rest of the signature and
body intact.

---

Outside diff comments:
In `@src/types/supabase.types.ts`:
- Around line 3201-3234: The generated TypeScript return types for the RPC that
include the org record have the created_at field typed as non-nullable string;
update the Returns object(s) in src/types/supabase.types.ts so that the
created_at property is typed as string | null (apply this change to both
overloaded Returns blocks that define the org shape) to match the database
column; locate the Returns {...} arrays that include "created_at" and replace
its type accordingly.

In `@supabase/functions/_backend/utils/supabase.types.ts`:
- Around line 3201-3234: The get_orgs_v7 return type declares created_at as
non-nullable but the orgs.created_at column is nullable; update the code to
ensure runtime/type alignment by either (A) marking created_at as nullable in
the get_orgs_v7 return type (i.e., allow string | null) so callers can handle
null, or (B) change the SQL that populates get_orgs_v7 to coalesce(created_at,
'1970-01-01T00:00:00Z') (or another safe default) before returning so the field
is guaranteed non-null; locate references to get_orgs_v7 and the created_at
field in supabase.types.ts and apply one of these two fixes consistently across
the function signature and its SQL selection.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5210f74 and ccaa212.

📒 Files selected for processing (4)
  • src/components/dashboard/TrialBanner.vue
  • src/types/supabase.types.ts
  • supabase/functions/_backend/utils/supabase.types.ts
  • supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql

…rval

- 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.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
src/components/dashboard/TrialBanner.vue (2)

160-166: ⚠️ Potential issue | 🟡 Minor

Apply DaisyUI base button class to the CTA.

This is an interactive element and should use the DaisyUI d-btn base class.

Minimal fix
-        class="cta-button cta-sparkle"
+        class="d-btn cta-button cta-sparkle"

As per coding guidelines, "Use DaisyUI components (d-btn, d-input, d-card) for interactive elements in Vue components".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dashboard/TrialBanner.vue` around lines 160 - 166, The CTA
router-link (ref="ctaRef", currently class="cta-button cta-sparkle") must
include the DaisyUI base button class; update the router-link's class attribute
to add "d-btn" (e.g., class="d-btn cta-button cta-sparkle") so the interactive
element uses the required DaisyUI button component styling.

63-106: ⚠️ Potential issue | 🟡 Minor

Rename MAX_TRAVEL/EXCITE_DISTANCE to camelCase.

Uppercase constants violate the camelCase rule for TS/JS variables.

Suggested update
-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)
 ...
-  const easedDist = Math.min(dist * 0.1, MAX_TRAVEL)
+  const easedDist = Math.min(dist * 0.1, maxTravel)
 ...
-const EXCITE_DISTANCE = 80 // px from CTA edge to trigger excitement
+const exciteDistance = 80 // px from CTA edge to trigger excitement
 ...
-    excited.value = distToRect(e.clientX, e.clientY, ctaRect) < EXCITE_DISTANCE
+    excited.value = distToRect(e.clientX, e.clientY, ctaRect) < exciteDistance

As per coding guidelines, "Use camelCase for variable names in TypeScript and JavaScript".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/dashboard/TrialBanner.vue` around lines 63 - 106, The
constants MAX_TRAVEL and EXCITE_DISTANCE are using UPPER_SNAKE_CASE but must
follow camelCase; rename them to maxTravel and exciteDistance and update all
usages (change the declaration const MAX_TRAVEL = 4 to const maxTravel = 4 and
const EXCITE_DISTANCE = 80 to const exciteDistance = 80), then update references
inside calcOffset (where MAX_TRAVEL is used) and inside handleMouseMove (where
EXCITE_DISTANCE is compared) so the identifiers match the new camelCase names.
supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql (2)

44-44: get_orgs_v7(uuid) is still declared STABLE while calling VOLATILE helpers

This was flagged on a previous commit and remains unresolved. The function calls has_2fa_enabled, user_meets_password_policy, and get_next_cron_time, all of which are VOLATILE. A STABLE function "cannot modify the database and is guaranteed to return the same results given the same arguments for all rows within a single statement." Calling VOLATILE helpers breaks this contract and can produce stale cached results.

Change STABLE to VOLATILE on line 44.

🐛 Proposed fix
-) LANGUAGE plpgsql STABLE SECURITY DEFINER
+) LANGUAGE plpgsql VOLATILE SECURITY DEFINER
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql` at line
44, The function declaration for get_orgs_v7(uuid) is incorrectly marked STABLE
while it calls VOLATILE helpers (has_2fa_enabled, user_meets_password_policy,
get_next_cron_time); update the function declaration keyword from STABLE to
VOLATILE so the planner knows the function can return varying results within a
statement and avoid caching/staleness issues.

149-166: paying, can_use_more, and is_canceled still return NULL instead of false when no stripe row exists

This was flagged on a previous commit and remains unresolved. The stripe_info join is a LEFT JOIN (line 221), so when no billing record exists the ELSE branches on lines 151, 159–161, and 165 all evaluate to NULL. Wrap each ELSE expression with COALESCE(..., false) as suggested in the prior review.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql` around
lines 149 - 166, The CASE branches for paying, can_use_more, and is_canceled
currently return NULL when no stripe_info (si) row exists; update each ELSE
expression in the CASE for fields paying, can_use_more, and is_canceled to wrap
the boolean result with COALESCE(..., false) (e.g., COALESCE((si.status =
'succeeded'), false)), so that when the LEFT JOIN yields NULL you get false;
reference the aliases si, tfa, ppa, and ucb in the same CASEs to locate and
modify the expressions.
🧹 Nitpick comments (3)
supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql (3)

324-326: GRANT ALL on the wrapper is inconsistent with GRANT EXECUTE used above

GRANT ALL ON FUNCTION is functionally identical to GRANT EXECUTE ON FUNCTION for PostgreSQL functions (EXECUTE is the only applicable privilege), but the inconsistency with lines 238–239 reduces clarity.

♻️ Proposed fix
-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;
+GRANT EXECUTE ON FUNCTION public.get_orgs_v7() TO anon;
+GRANT EXECUTE ON FUNCTION public.get_orgs_v7() TO authenticated;
+GRANT EXECUTE ON FUNCTION public.get_orgs_v7() TO service_role;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql` around
lines 324 - 326, Replace the three inconsistent GRANT statements for the wrapper
function with explicit EXECUTE privileges: instead of "GRANT ALL ON FUNCTION
public.get_orgs_v7() TO anon/authenticated/service_role", change each to "GRANT
EXECUTE ON FUNCTION public.get_orgs_v7() TO <role>" so they match the earlier
GRANT EXECUTE usage for get_orgs_v7 and improve clarity.

87-113: System-wide scans in paying_orgs_ordered and billing_cycles CTEs scale with total org count

paying_orgs_ordered uses ROW_NUMBER() OVER (ORDER BY o.id ASC) over the entire orgs + stripe_info join — window functions prevent predicate pushdown, so PostgreSQL must always read every paying org in the system, regardless of how many orgs the calling user actually belongs to. billing_cycles similarly scans all of orgs and stripe_info.

At scale, this becomes a full-table read on every dashboard load. Consider:

  • Pre-computing and storing preceding_count as a materialised column on orgs (updated by the stats job) so the per-user query avoids the window function entirely.
  • Adding a covering index on stripe_info(customer_id, status, canceled_at, subscription_anchor_end, trial_at) if one does not already exist, to make the paying_orgs_ordered join cheaper.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql` around
lines 87 - 113, The paying_orgs_ordered CTE (ROW_NUMBER() OVER ... as
preceding_count) and the billing_cycles CTE currently scan public.orgs JOIN
public.stripe_info for the whole table; replace the window-based full scan by
either using a precomputed/materialized preceding_count column on orgs (update
via your periodic stats job and reference that column instead of computing
ROW_NUMBER in paying_orgs_ordered) or restrict the CTE to only the current
user's org set (apply a WHERE org_id IN (...) or join to the caller's membership
table before the window) and remove the window function; also add a covering
index on public.stripe_info(customer_id, status, canceled_at,
subscription_anchor_end, trial_at) if it doesn't exist to speed the join used by
paying_orgs_ordered and billing_cycles.

120-134: Each of has_2fa_enabled and user_meets_password_policy is called twice per row

In two_fa_access, public.has_2fa_enabled(userid) appears on both line 122 and line 124. In password_policy_access, public.user_meets_password_policy(userid, o.id) appears on both lines 133 and 134. Because these are VOLATILE functions, PostgreSQL evaluates each occurrence independently — doubling the call count per org row.

Both should_redact_2fa and should_redact_password are simply the logical negations of their corresponding _has_access columns; they don't need to be materialised in the CTE.

♻️ Proposed refactor
  -- Calculate 2FA access status for user/org combinations
+ user_2fa_result AS (
+   SELECT public.has_2fa_enabled(userid) AS result
+ ),
  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)
+       ELSE u.result
      END AS "2fa_has_access",
-     (o.enforcing_2fa = true AND NOT public.has_2fa_enabled(userid)) AS should_redact_2fa
+     (o.enforcing_2fa = true AND NOT (CASE WHEN o.enforcing_2fa = false THEN true ELSE u.result END)) AS should_redact_2fa
    FROM public.orgs o
    JOIN user_orgs uo ON uo.org_id = o.id
+   CROSS JOIN user_2fa_result u
  ),
  -- 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
+     public.user_meets_password_policy(userid, o.id) AS password_has_access
    FROM public.orgs o
    JOIN user_orgs uo ON uo.org_id = o.id
  )

Then replace all ppa.should_redact_password references in the main SELECT with NOT ppa.password_has_access, and tfa.should_redact_2fa with NOT tfa."2fa_has_access".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql` around
lines 120 - 134, The CTEs two_fa_access and password_policy_access call the
VOLATILE functions public.has_2fa_enabled(userid) and
public.user_meets_password_policy(userid, o.id) twice per row (once for
*_has_access and again for should_redact_*); remove the redundant function calls
by computing only the *_has_access columns in two_fa_access and
password_policy_access (using public.has_2fa_enabled and
public.user_meets_password_policy once each) and drop should_redact_2fa /
should_redact_password from those CTEs, then update the main SELECT to use NOT
tfa."2fa_has_access" and NOT ppa.password_has_access wherever should_redact_2fa
/ should_redact_password were referenced.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/dashboard/TrialBanner.vue`:
- Around line 137-179: The .eyes-container layout/spacing CSS should be moved to
Tailwind utilities: update the template's div with class="eyes-container" to
also include utility classes like "flex gap-3 items-center justify-center
flex-shrink-0 px-1" (or equivalent spacing utilities you prefer) and remove the
corresponding properties (display, gap, align-items, justify-content,
flex-shrink, padding) from the <style scoped> block, leaving only the transition
rule (and any animation-related styles) in .eyes-container; ensure the reactive
class binding ( :class="{ 'eyes-excited': excited }" ) and the .eyes-container
selector remain in place so the existing transition and excited styling continue
to work.

---

Duplicate comments:
In `@src/components/dashboard/TrialBanner.vue`:
- Around line 160-166: The CTA router-link (ref="ctaRef", currently
class="cta-button cta-sparkle") must include the DaisyUI base button class;
update the router-link's class attribute to add "d-btn" (e.g., class="d-btn
cta-button cta-sparkle") so the interactive element uses the required DaisyUI
button component styling.
- Around line 63-106: The constants MAX_TRAVEL and EXCITE_DISTANCE are using
UPPER_SNAKE_CASE but must follow camelCase; rename them to maxTravel and
exciteDistance and update all usages (change the declaration const MAX_TRAVEL =
4 to const maxTravel = 4 and const EXCITE_DISTANCE = 80 to const exciteDistance
= 80), then update references inside calcOffset (where MAX_TRAVEL is used) and
inside handleMouseMove (where EXCITE_DISTANCE is compared) so the identifiers
match the new camelCase names.

In `@supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql`:
- Line 44: The function declaration for get_orgs_v7(uuid) is incorrectly marked
STABLE while it calls VOLATILE helpers (has_2fa_enabled,
user_meets_password_policy, get_next_cron_time); update the function declaration
keyword from STABLE to VOLATILE so the planner knows the function can return
varying results within a statement and avoid caching/staleness issues.
- Around line 149-166: The CASE branches for paying, can_use_more, and
is_canceled currently return NULL when no stripe_info (si) row exists; update
each ELSE expression in the CASE for fields paying, can_use_more, and
is_canceled to wrap the boolean result with COALESCE(..., false) (e.g.,
COALESCE((si.status = 'succeeded'), false)), so that when the LEFT JOIN yields
NULL you get false; reference the aliases si, tfa, ppa, and ucb in the same
CASEs to locate and modify the expressions.

---

Nitpick comments:
In `@supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql`:
- Around line 324-326: Replace the three inconsistent GRANT statements for the
wrapper function with explicit EXECUTE privileges: instead of "GRANT ALL ON
FUNCTION public.get_orgs_v7() TO anon/authenticated/service_role", change each
to "GRANT EXECUTE ON FUNCTION public.get_orgs_v7() TO <role>" so they match the
earlier GRANT EXECUTE usage for get_orgs_v7 and improve clarity.
- Around line 87-113: The paying_orgs_ordered CTE (ROW_NUMBER() OVER ... as
preceding_count) and the billing_cycles CTE currently scan public.orgs JOIN
public.stripe_info for the whole table; replace the window-based full scan by
either using a precomputed/materialized preceding_count column on orgs (update
via your periodic stats job and reference that column instead of computing
ROW_NUMBER in paying_orgs_ordered) or restrict the CTE to only the current
user's org set (apply a WHERE org_id IN (...) or join to the caller's membership
table before the window) and remove the window function; also add a covering
index on public.stripe_info(customer_id, status, canceled_at,
subscription_anchor_end, trial_at) if it doesn't exist to speed the join used by
paying_orgs_ordered and billing_cycles.
- Around line 120-134: The CTEs two_fa_access and password_policy_access call
the VOLATILE functions public.has_2fa_enabled(userid) and
public.user_meets_password_policy(userid, o.id) twice per row (once for
*_has_access and again for should_redact_*); remove the redundant function calls
by computing only the *_has_access columns in two_fa_access and
password_policy_access (using public.has_2fa_enabled and
public.user_meets_password_policy once each) and drop should_redact_2fa /
should_redact_password from those CTEs, then update the main SELECT to use NOT
tfa."2fa_has_access" and NOT ppa.password_has_access wherever should_redact_2fa
/ should_redact_password were referenced.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ccaa212 and 3ab393d.

📒 Files selected for processing (4)
  • src/components/dashboard/TrialBanner.vue
  • src/types/supabase.types.ts
  • supabase/functions/_backend/utils/supabase.types.ts
  • supabase/migrations/20260224120000_add_created_at_to_get_orgs_v7.sql
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/types/supabase.types.ts
  • supabase/functions/_backend/utils/supabase.types.ts

- 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
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.
@sonarqubecloud
Copy link

@riderx riderx merged commit 1e0e532 into main Feb 24, 2026
14 checks passed
@riderx riderx deleted the feat/trial-banner-eyes-sparkle branch February 24, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants