Skip to content

Payments redesign#1045

Merged
N2D4 merged 24 commits intodevfrom
Payments-Redesign
Dec 18, 2025
Merged

Payments redesign#1045
N2D4 merged 24 commits intodevfrom
Payments-Redesign

Conversation

@Developing-Gamer
Copy link
Contributor

@Developing-Gamer Developing-Gamer commented Dec 3, 2025

Summary by CodeRabbit

  • New Features

    • New Catalogs area and improved Products & Items workflow: list, details, create product flow, product editor, price editor, previews, and pricing management.
    • New reusable controls: EditableGrid, RepeatingInput, Price editor dialog, and PricingSection.
  • Style

    • Visual refresh: updated headers, search, spacing, borders, popovers, dialogs, inputs, dropdowns, tooltips, and improved code block wrapping/scrolling.
  • Bug Fixes / Chores

    • Input sanitization, safer delete/navigation flows, refined test-mode/onboarding UI, email queue gating flags, and e2e timing/stability tweaks.

✏️ Tip: You can customize this high-level summary in your review settings.


Note

Redesigns Payments with Catalogs and rich Product editing flows, and migrates to a new in-house UI kit replacing stack-ui.

  • Payments:
    • Catalogs: New pages payments/catalogs/* with onboarding/test-mode banners and Connect notifications.
    • Products: New creation flow payments/products/new, product detail payments/products/[productId], customers table, and refreshed list view with CSV export and safer deletes.
    • Editors: PricingSection, PriceEditDialog, IncludedItemDialog, EditableGrid, EditableInput, RepeatingInput, ProductCardPreview for prices/items/free trials and previews.
  • UI Kit Migration:
    • Replace @stackframe/stack-ui with local @/components/ui (Radix-based) across pages; add many primitives (button, dialog, dropdown-menu, table, toast, etc.).
    • Add dependencies (Radix packages, cmdk, react-resizable-panels, react-day-picker, class-variance-authority, etc.).
  • Validation/Utils:
    • Introduce isValidUserSpecifiedId, sanitizeUserSpecifiedId, getUserSpecifiedIdErrorMessage; apply to catalog/price/item/store ID inputs.
  • Styling/UX:
    • New banners for test mode/onboarding, refined headers/search/dialogs, code block wrapping, and consistent actions/navigation.

Written by Cursor Bugbot for commit 6ab66d6. This will update automatically on new commits. Configure here.

@Developing-Gamer Developing-Gamer requested a review from N2D4 December 3, 2025 19:23
@vercel
Copy link

vercel bot commented Dec 3, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
stack-backend Ready Ready Preview, Comment Dec 18, 2025 0:39am
stack-dashboard Ready Ready Preview, Comment Dec 18, 2025 0:39am
stack-demo Ready Ready Preview, Comment Dec 18, 2025 0:39am
stack-docs Ready Ready Preview, Comment Dec 18, 2025 0:39am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 3, 2025

Important

Review skipped

More than 25% of the files skipped due to max files limit. The review is being skipped to prevent a low-quality review.

74 files out of 182 files are above the max files limit of 100. Please upgrade to Pro plan to get higher limits.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds Catalogs pages and a client-side Product editor, refactors Payments UI (interval picker, dialogs, lists), safer deletion via typedEntries/typedFromEntries, email-queue env flags and boolean env helper, CI wait-port updates, new e2e tests, and several new components/exports (EditableGrid, RepeatingInput, PricingSection, PriceEditDialog).

Changes

Cohort / File(s) Summary
Catalogs layout & pages
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/catalogs/layout.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/catalogs/page-client.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/catalogs/page.tsx
New Catalogs layout and pages: Stripe onboarding flows, country-aware SetupPayments modal, test-mode banner/toggles, ConnectNotificationBanner, welcome/draft flows and SetupPaymentsButton dialog.
Products pages & product editor
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx, .../page-client-list-view.tsx, .../page-client.tsx, .../page.tsx, .../[productId]/page-client.tsx, .../[productId]/page.tsx, .../new/page-client.tsx, .../new/page.tsx
Large product UI refactor: list/catalog views, full product detail editor and creation flow, centralized ActionDialog deletions, refresh/validation mechanics, and onCreateNewItem signature extended with an optional onCreated callback.
Interval / Repeating input & components
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx, apps/dashboard/src/components/repeating-input.tsx
Reworked IntervalPopover/RepeatingInput: native button triggers, readOnly guards, new custom interval layout, exported RepeatingInput and props, and className/styling updates.
Pricing UI & helpers
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx, .../price-edit-dialog.tsx, .../product-price-row.tsx, .../product-card-preview.tsx, .../utils.ts
New PricingSection and PriceEditDialog, editing helpers and conversions, added formatPriceDisplay export, buildPriceUpdate freeTrial now accepts null, and savePriceUpdate type tightened.
Product dialogs, item dialog & price row
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx, apps/dashboard/src/components/payments/item-dialog.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
Restyled dialogs (cn usage), header icons, ID sanitization, refined inputs/selects, and updated styling/validation for product/item dialogs and price rows.
Editable grid & inputs
apps/dashboard/src/components/editable-grid.tsx, apps/dashboard/src/components/editable-input.tsx
New EditableGrid component and exported type; minor readOnly/full-width styling tweaks for editable inputs.
List header, search & list views
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx, .../page-client-list-view.tsx
Header restyle with custom search input and add button; updated layout/typography; centralized delete dialogs and row navigation handlers.
Data table & deletion logic
apps/dashboard/src/components/data-table/payment-product-table.tsx
Product deletion now rebuilds the products map using typedEntries/typedFromEntries and filters removed entries; toasts preserved.
Dropdown menu UI
packages/stack-ui/src/components/ui/dropdown-menu.tsx
Increased padding/radii, bordered subcontent style, and added optional icon?: React.ReactNode prop with left-icon rendering.
Input/Select visual tweaks
packages/stack-ui/src/components/ui/input.tsx, packages/stack-ui/src/components/ui/select.tsx
Removed backdrop-blur/background classes from Input and SelectTrigger — visual styling adjustments only.
New product preview & code helpers
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
ProductCardPreview component and live preview / inline code generation added for new product creation flow.
Code block wrapping
apps/dashboard/src/components/code-block.tsx
Added horizontal scroll wrapper, enabled wrapLongLines, and adjusted SyntaxHighlighter wrapping/word-break behavior.
Email queue flags & env util
apps/backend/.env, apps/backend/src/lib/emails.tsx, apps/backend/src/lib/email-queue-step.tsx, packages/stack-shared/src/utils/env.tsx
Added env flags to control auto-triggering and sending of email queue; introduced getEnvBoolean(name) with strict boolean parsing; gated runEmailQueueStep and added simulated error path when sending is disabled.
CI workflows & e2e tests
.github/workflows/e2e-*.yaml (multiple), apps/e2e/tests/backend/backend-helpers.ts, apps/e2e/tests/backend/endpoints/api/v1/pending-users.test.ts
Updated CI wait-on URLs/ports, increased mailbox polling backoff, and added comprehensive pending-users e2e tests for email verification/pending-state behavior.
Frontend nav & metadata
apps/dashboard/src/lib/apps-frontend.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page.tsx, apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/catalogs/page.tsx
Split Payments nav into "Catalogs" and "Products & Items"; updated route metadata titles for products and added catalogs page metadata.
Misc small exports/props
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx, apps/dashboard/src/components/payments/item-dialog.tsx, packages/stack-shared/src/utils/env.tsx
Minor signature changes: CreateCatalogDialog onCreate may return Promise, new getEnvBoolean export, and other small prop/type adjustments.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Areas needing extra attention:
    • onCreateNewItem signature change — ensure all callers handle optional onCreated callback.
    • buildPriceUpdate freeTrial now nullable and new formatPriceDisplay — verify callers and serialization.
    • Deletion/map reconstruction using typedEntries/typedFromEntries — check merge/race conditions with project config updates.
    • Email queue flags and getEnvBoolean — validate environment values and simulated-error behavior in production/CI.
    • New public components/exports (EditableGrid, RepeatingInput, PricingSection, PriceEditDialog) — confirm typings and downstream integration.

Possibly related PRs

Suggested reviewers

  • BilalG1

Poem

🐇 I nibbled at classes, smoothed each seam,

Popovers hum softly and previews gleam.
IDs are tidy, deletions rebuild right,
Env flags whisper, CI ports take flight.
Hop—merge it in; the dashboard’s delight.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.73% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Payments redesign' is concise and directly reflects the main focus of the changeset—a comprehensive redesign of the Payments dashboard UI.
Description check ✅ Passed The pull request description follows the basic template structure with a cursor-generated summary highlighting key changes across payments, UI, and backend areas.

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.

@Developing-Gamer Developing-Gamer force-pushed the Payments-Redesign branch 2 times, most recently from 56d73e2 to 726286c Compare December 3, 2025 21:00
@Developing-Gamer Developing-Gamer marked this pull request as ready for review December 4, 2025 01:03
Copilot AI review requested due to automatic review settings December 4, 2025 01:03
@claude
Copy link
Contributor

claude bot commented Dec 4, 2025

Claude encountered an error —— View job


I'll analyze this and get back to you.

@claude
Copy link
Contributor

claude bot commented Dec 4, 2025

Claude encountered an error —— View job


I'll analyze this and get back to you.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Dec 4, 2025

Greptile Overview

Greptile Summary

This PR implements a comprehensive redesign of the payments products management UI with improved user experience and visual design.

Key Changes

  • Redesigned pricing table catalog view with inline editing and visual card-based layout
  • Added welcome screen for first-time users with illustrated onboarding
  • Introduced product templates (one-time, subscription, add-on) for faster setup
  • Enhanced list view with connection lines visualizing product-item relationships
  • Improved component reusability with new IntervalPopover and section heading components
  • Added comprehensive implementation prompts and code snippets for developers

Issues Found

  • Missing error handling: Several async button click handlers in both catalog and list views don't use runAsynchronouslyWithAlert as required by project standards (lines flagged in comments)
  • The custom instruction requires using runAsynchronouslyWithAlert for all async button handlers and form submissions to ensure errors are shown to users

Design Quality

The redesign significantly improves the UX with better visual hierarchy, inline editing, and intuitive workflows. The code is well-structured with good separation of concerns.

Confidence Score: 3/5

  • This PR is safe to merge after fixing the error handling issues in async button handlers
  • Score reflects the presence of 4 critical missing error handlers that violate project standards. While the UI redesign is comprehensive and well-implemented, the missing runAsynchronouslyWithAlert wrappers could lead to silent failures where users don't see error messages when operations fail. These are straightforward to fix.
  • Pay close attention to page-client-catalogs-view.tsx and page-client-list-view.tsx - both have async button handlers missing proper error handling

Important Files Changed

File Analysis

Filename Score Overview
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx 3/5 Major UI refactoring for catalog view - improved product/catalog management, but missing error handling on async button handlers
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx 3/5 Redesigned list view with improved UX and connection visualizations - missing error handling on delete operations
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx 4/5 Enhanced product creation dialog with template selection and multi-step wizard - well structured and comprehensive
apps/dashboard/src/components/payments/item-dialog.tsx 5/5 Item creation/editing dialog - straightforward implementation with proper validation
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx 5/5 Main page with welcome screen and view toggle between list/catalog views - clean implementation

Sequence Diagram

sequenceDiagram
    participant User
    participant PageClient
    participant CatalogView
    participant ProductCard
    participant ProductDialog
    participant ItemDialog
    participant Project

    User->>PageClient: Opens payments page
    alt No products/items exist
        PageClient->>User: Shows welcome screen
        User->>PageClient: Clicks "Create First Product"
        PageClient->>CatalogView: Creates draft product
    else Products exist
        PageClient->>CatalogView: Shows catalog view (default)
        Note over PageClient,CatalogView: User can toggle between<br/>catalog view and list view
    end

    User->>ProductCard: Clicks "Add product"
    ProductCard->>ProductCard: Creates draft in edit mode
    User->>ProductCard: Edits product details
    User->>ProductCard: Adds prices and items
    User->>ProductCard: Clicks "Save"
    ProductCard->>Project: updateConfig(product)
    Project-->>ProductCard: Success
    ProductCard->>CatalogView: Updates display

    User->>ProductCard: Clicks "Add Item"
    ProductCard->>ItemDialog: Opens create dialog
    User->>ItemDialog: Enters item details
    ItemDialog->>Project: updateConfig(item)
    Project-->>ItemDialog: Success
    ItemDialog->>ProductCard: Returns new item ID
    ProductCard->>ProductCard: Auto-selects new item

    User->>ProductCard: Hovers over product
    ProductCard->>CatalogView: Shows connections to items
    
    User->>ProductCard: Clicks delete
    ProductCard->>Project: Deletes product & empty catalog
    Project-->>ProductCard: Success
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

13 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

size="sm"
className="h-9 rounded-lg px-5 bg-foreground text-background hover:bg-foreground/90"
disabled={!canSaveProduct}
onClick={async () => { await handleSaveEdit(); }}
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Missing error handling for async operation - should use runAsynchronouslyWithAlert from @stackframe/stack-shared/dist/utils/promises instead of inline async handler

Suggested change
onClick={async () => { await handleSaveEdit(); }}
onClick={() => runAsynchronouslyWithAlert(async () => { await handleSaveEdit(); })}

Context Used: Rule from dashboard - Use runAsynchronouslyWithAlert from @stackframe/stack-shared/dist/utils/promises for async butto... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
Line: 1198:1198

Comment:
**logic:** Missing error handling for async operation - should use `runAsynchronouslyWithAlert` from `@stackframe/stack-shared/dist/utils/promises` instead of inline async handler

```suggestion
              onClick={() => runAsynchronouslyWithAlert(async () => { await handleSaveEdit(); })}
```

**Context Used:** Rule from `dashboard` - Use `runAsynchronouslyWithAlert` from `@stackframe/stack-shared/dist/utils/promises` for async butto... ([source](https://app.greptile.com/review/custom-context?memory=5e671275-7493-402a-93a8-969537ec4d63))

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 1041 to +1343
onClick: async () => {
await onDelete(id);
setShowDeleteDialog(false);
setIsEditing(false);
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Missing error handling for async operation - use runAsynchronouslyWithAlert

Suggested change
onClick: async () => {
await onDelete(id);
setShowDeleteDialog(false);
setIsEditing(false);
onClick: () => runAsynchronouslyWithAlert(async () => {
await onDelete(id);
setIsEditing(false);
})

Context Used: Rule from dashboard - Use runAsynchronouslyWithAlert from @stackframe/stack-shared/dist/utils/promises for async butto... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
Line: 1341:1343

Comment:
**logic:** Missing error handling for async operation - use `runAsynchronouslyWithAlert`

```suggestion
          onClick: () => runAsynchronouslyWithAlert(async () => {
            await onDelete(id);
            setIsEditing(false);
          })
```

**Context Used:** Rule from `dashboard` - Use `runAsynchronouslyWithAlert` from `@stackframe/stack-shared/dist/utils/promises` for async butto... ([source](https://app.greptile.com/review/custom-context?memory=5e671275-7493-402a-93a8-969537ec4d63))

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +443 to +451
onClick: async () => {
if (!productToDelete) return;
const config = project.useConfig();
const updatedProducts = typedFromEntries(
typedEntries(config.payments.products)
.filter(([productId]) => productId !== productToDelete.id)
);
await project.updateConfig({ "payments.products": updatedProducts });
toast({ title: "Product deleted" });
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: Missing error handling - use runAsynchronouslyWithAlert for async onClick handler

Suggested change
onClick: async () => {
if (!productToDelete) return;
const config = project.useConfig();
const updatedProducts = typedFromEntries(
typedEntries(config.payments.products)
.filter(([productId]) => productId !== productToDelete.id)
);
await project.updateConfig({ "payments.products": updatedProducts });
toast({ title: "Product deleted" });
onClick: () => runAsynchronouslyWithAlert(async () => {
if (!productToDelete) return;
const config = project.useConfig();
const updatedProducts = typedFromEntries(
typedEntries(config.payments.products)
.filter(([productId]) => productId !== productToDelete.id)
);
await project.updateConfig({ "payments.products": updatedProducts });
toast({ title: "Product deleted" });
setProductToDelete(null);
})

Context Used: Rule from dashboard - Use runAsynchronouslyWithAlert from @stackframe/stack-shared/dist/utils/promises for async butto... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
Line: 443:451

Comment:
**logic:** Missing error handling - use `runAsynchronouslyWithAlert` for async onClick handler

```suggestion
          onClick: () => runAsynchronouslyWithAlert(async () => {
            if (!productToDelete) return;
            const config = project.useConfig();
            const updatedProducts = typedFromEntries(
              typedEntries(config.payments.products)
                .filter(([productId]) => productId !== productToDelete.id)
            );
            await project.updateConfig({ "payments.products": updatedProducts });
            toast({ title: "Product deleted" });
            setProductToDelete(null);
          })
```

**Context Used:** Rule from `dashboard` - Use `runAsynchronouslyWithAlert` from `@stackframe/stack-shared/dist/utils/promises` for async butto... ([source](https://app.greptile.com/review/custom-context?memory=5e671275-7493-402a-93a8-969537ec4d63))

How can I resolve this? If you propose a fix, please make it concise.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements a comprehensive visual redesign of the payments interface with improved styling, better user experience, and several bug fixes. The changes modernize the UI with updated color schemes, improved spacing, rounded corners, and enhanced visual feedback while also addressing data handling issues.

Key Changes:

  • Redesigned UI components with modern styling (rounded corners, improved shadows, better color contrast)
  • Fixed async state management bugs in price editing
  • Improved product/item deletion logic to properly rebuild config objects
  • Enhanced form validation and error messaging
  • Added comprehensive code generation prompts for AI assistance

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
dropdown-menu.tsx Updated spacing/padding, added icon prop support for menu items
item-dialog.tsx Redesigned dialog with improved styling, fixed input validation to allow underscores
payment-product-table.tsx Fixed product deletion to rebuild config object instead of setting to null
code-block.tsx Added overflow handling and text wrapping for better code display
utils.ts Enhanced freeTrial parameter handling to distinguish between removal and setting
product-price-row.tsx Fixed async state bugs, redesigned UI with better visual feedback
product-dialog.tsx Comprehensive styling updates with improved form layouts and validation
page-client.tsx Redesigned view toggle from switches to modern button group
page-client-list-view.tsx Updated list UI with improved styling, replaced confirm() with ActionDialog
page-client-catalogs-view.tsx Major refactoring with ValidationError class, AI prompt generation, improved product management
list-section.tsx Modernized search UI with better focus states and styling
layout.tsx Redesigned test mode and setup banners with improved information hierarchy
components.tsx Updated popover styling for consistency with new design system

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1724 to 1728
useEffect(() => {
// This effect will run whenever paymentsConfig.products changes
// The productsKey dependency in useMemo above should handle most cases,
// but this ensures we catch any edge cases
}, [productsKey]);
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

This useEffect hook is empty and serves no purpose. The comment on line 1725 explains it's meant to "catch any edge cases", but an empty effect does nothing. The dependency on productsKey would cause the effect to run when products change, but without any code in the effect body, it has no observable effect.

Consider removing this empty effect:

// Remove lines 1724-1728

If the intent was to force a re-render, there are better patterns like using the refreshKey state that's already defined on line 1698.

Suggested change
useEffect(() => {
// This effect will run whenever paymentsConfig.products changes
// The productsKey dependency in useMemo above should handle most cases,
// but this ensures we catch any edge cases
}, [productsKey]);

Copilot uses AI. Check for mistakes.
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<LabelWithInfo tooltip="The display name shown to customers on checkout pages and invoices">
Offer Name
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

[nitpick] The label text "Offer Name" is inconsistent with other parts of the codebase that use "Display Name" (e.g., item-dialog.tsx line 149, product-dialog.tsx line 448). While "Offer Name" might be intentional for product context, this inconsistency could confuse users.

Consider using "Display Name" for consistency, or if "Offer" is specific terminology for products, ensure it's used consistently throughout the product-related UI.

Suggested change
Offer Name
Display Name

Copilot uses AI. Check for mistakes.
value={productId}
onChange={(e) => {
const nextValue = e.target.value.toLowerCase();
const nextValue = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

The onChange handler filters the product ID to remove invalid characters using .replace(/[^a-z0-9-]/g, ''), but this differs from the validation pattern used later which is /^[a-z0-9-]+$/. The filter removes underscores if typed, but underscores are allowed in item IDs (as seen in item-dialog.tsx line 40). This creates an inconsistency between products and items.

Consider either:

  1. Allowing underscores in product IDs to match item IDs: .replace(/[^a-z0-9_-]/g, '')
  2. Or documenting why products don't allow underscores while items do
Suggested change
const nextValue = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
const nextValue = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '');

Copilot uses AI. Check for mistakes.
const handleCreateItem = (customerType?: 'user' | 'team' | 'custom') => {
const handleCreateItem = (customerType?: 'user' | 'team' | 'custom', onCreated?: (itemId: string) => void) => {
setNewItemCustomerType(customerType);
setOnItemCreatedCallback(() => onCreated);
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

The callback function stored in state is being wrapped in an arrow function on line 1818, which can cause issues. When setOnItemCreatedCallback is called with () => onCreated, it stores a function that returns the callback, not the callback itself.

This should be:

setOnItemCreatedCallback(() => onCreated);

Or better yet, store the function directly without wrapping:

const [onItemCreatedCallback, setOnItemCreatedCallback] = useState<((itemId: string) => void) | undefined>(undefined);
// ...
setOnItemCreatedCallback(onCreated);

However, looking at line 1836, the callback is invoked correctly as onItemCreatedCallback(itemId), so this might work but is unconventional. The typical pattern would be to store the function reference directly.

Suggested change
setOnItemCreatedCallback(() => onCreated);
setOnItemCreatedCallback(onCreated);

Copilot uses AI. Check for mistakes.
Comment on lines 1706 to 1710
const productIds = Object.entries(paymentsConfig.products)
.filter(([, product]) => product != null)
.map(([id]) => id)
.sort()
.join(',');
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

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

The code creates a productsKey memoized value and a productIds variable that serve the same purpose (tracking product changes), but only productsKey is used. The productIds variable on lines 1706-1710 is computed but never used, making it dead code.

Consider removing the unused productIds variable:

// Remove lines 1706-1710
Suggested change
const productIds = Object.entries(paymentsConfig.products)
.filter(([, product]) => product != null)
.map(([id]) => id)
.sort()
.join(',');

Copilot uses AI. Check for mistakes.
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: 5

🧹 Nitpick comments (11)
apps/dashboard/src/components/code-block.tsx (1)

62-87: Consolidate horizontal scrolling to avoid nested scrollbars

The new overflow-x-auto wrapper (Line 62) plus overflow: 'auto' in customStyle (Line 74) means both the parent and the <pre> can create horizontal scrollbars. This can produce awkward nested scrolling for wide code blocks.

You can keep the intended behavior (horizontal scroll at the wrapper, vertical scroll inside) by scoping overflow on the highlighter to vertical only:

         customStyle={{
           background: 'transparent',
           padding: '1em',
           border: 0,
           boxShadow: 'none',
           margin: 0,
           fontSize: '0.875rem',
           maxHeight: props.maxHeight,
-          overflow: 'auto',
+          overflowY: 'auto',
           whiteSpace: 'pre-wrap',
           wordBreak: 'break-word',

This keeps vertical scrolling for tall snippets while the new wrapper cleanly handles horizontal overflow without double scrollbars.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx (2)

131-144: Simplify the onClick handler.

The arrow function wrapper is unnecessary since handleDisableTestMode takes no arguments.

Apply this diff:

-              onClick={() => handleDisableTestMode()}
+              onClick={handleDisableTestMode}

184-206: Simplify onClick handlers.

Both arrow function wrappers are unnecessary since the handlers take no arguments.

Apply this diff:

-                  onClick={() => handleEnableTestMode()}
+                  onClick={handleEnableTestMode}
-                  onClick={() => setupPayments()}
+                  onClick={setupPayments}
packages/stack-ui/src/components/ui/dropdown-menu.tsx (1)

84-87: Inconsistent indentation in className attribute.

The className attribute has unusual indentation that doesn't match the pattern used elsewhere in this file (e.g., DropdownMenuContent on lines 102-106).

   <DropdownMenuPrimitive.SubContent
     ref={ref}
-      className={cn(
-        "stack-scope z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border/50 bg-popover p-1.5 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
-        className
-      )}
+    className={cn(
+      "stack-scope z-50 min-w-[8rem] overflow-hidden rounded-xl border border-border/50 bg-popover p-1.5 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+      className
+    )}
     {...props}
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx (1)

19-35: Consider extracting LabelWithInfo to a shared location.

This component is duplicated in page-client-catalogs-view.tsx. Consider extracting it to components.tsx or a shared UI utilities file to avoid duplication.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (1)

572-590: Inconsistent deletion pattern between products and items.

Products use typedFromEntries/typedEntries to rebuild the entire products object (line 446-449), but items use the simpler path notation payments.items.${itemToDelete.id}: null (line 582). While both work, the inconsistency may cause confusion. Consider using the same approach for both, or document why they differ.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (2)

128-144: LabelWithInfo is duplicated across files.

This component is identical to the one in product-price-row.tsx. Extract it to components.tsx alongside OrSeparator, SectionHeading, and IntervalPopover to avoid duplication.


524-588: The generateComprehensivePrompt function is quite large.

At ~250 lines, this prompt generation function is substantial. Consider extracting it to a separate utility file (e.g., prompt-generator.ts) for better maintainability, especially if the prompt format evolves over time.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (2)

33-45: Header refactor & sticky layout look good; tooltip disabled prop is redundant

The sticky header, spacing, and typography updates look clean and consistent with the rest of the dashboard. One small simplification: since SimpleTooltip is only rendered when titleTooltip is truthy ({titleTooltip && (...)}), passing disabled={!titleTooltip} is effectively redundant. You can drop disabled here, or repurpose it only if you need to explicitly disable the tooltip even when a non-empty string is present.

Also applies to: 90-90


47-72: Search input UX is solid; consider minor accessibility improvements

The search control’s layout, width animation, and transition choices look good and align with the “snappy, hover-exit only” guideline (transition-all + hover:transition-none). To tighten accessibility:

  • Use type="search" instead of type="text" for better semantics and mobile keyboards.
  • Provide an explicit accessible name for the input (placeholder alone is not ideal for screen readers).
  • Mark the search icon decorative so it’s ignored by assistive tech.

For example:

-            {onSearchChange && (
+            {onSearchChange && (
               <div className={cn(
                 "relative flex items-center transition-all duration-150 hover:transition-none",
                 isSearchFocused ? "w-[180px]" : "w-[150px]"
               )}>
-                <div className="absolute left-3 flex items-center justify-center pointer-events-none z-10">
-                  <Search className="h-4 w-4 text-foreground/60" />
+                <div className="absolute left-3 flex items-center justify-center pointer-events-none z-10">
+                  <Search
+                    className="h-4 w-4 text-foreground/60"
+                    aria-hidden="true"
+                    focusable="false"
+                  />
                 </div>
-                <input
-                  type="text"
+                <input
+                  type="search"
+                  aria-label={searchPlaceholder}
                   placeholder={searchPlaceholder}

(Alternatively, you could wire this up to an external <label> instead of using aria-label.)

apps/dashboard/src/components/payments/item-dialog.tsx (1)

186-209: Customer type select wiring is fine; consider tightening the value typing

The select wiring (value, disabled, onValueChange) and the updated styling/helper text look good. If you want to avoid the as typeof customerType cast, you could narrow the callback parameter instead, for example:

onValueChange={(value: 'user' | 'team' | 'custom') => setCustomerType(value)}

This keeps type safety localized to the handler without relying on a cast.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8240af7 and dcfab53.

📒 Files selected for processing (13)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx (2 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (2 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (30 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (16 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (10 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx (12 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (4 hunks)
  • apps/dashboard/src/components/code-block.tsx (2 hunks)
  • apps/dashboard/src/components/data-table/payment-product-table.tsx (3 hunks)
  • apps/dashboard/src/components/payments/item-dialog.tsx (5 hunks)
  • packages/stack-ui/src/components/ui/dropdown-menu.tsx (10 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: For blocking alerts and errors, never use toast, as they are easily missed by the user. Instead, use alerts
Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition; apply transitions after the action instead
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error). Use runAsynchronously or runAsynchronouslyWithAlert instead for asynchronous error handling
When creating hover transitions, avoid hover-enter transitions and just use hover-exit transitions (e.g., transition-colors hover:transition-none)
Use ES6 maps instead of records wherever possible

Files:

  • apps/dashboard/src/components/code-block.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
  • packages/stack-ui/src/components/ui/dropdown-menu.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
apps/{backend,dashboard}/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them. Instead, prefer using a client component and prefer usePathname instead of await params

Files:

  • apps/dashboard/src/components/code-block.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
🧠 Learnings (1)
📚 Learning: 2025-11-28T21:21:39.142Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.142Z
Learning: Applies to **/*.{ts,tsx} : Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition; apply transitions after the action instead

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
🧬 Code graph analysis (8)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx (2)
docs/src/components/icons.tsx (1)
  • ChevronsUpDown (88-91)
packages/stack-shared/src/utils/dates.tsx (1)
  • DayInterval (146-146)
apps/dashboard/src/components/data-table/payment-product-table.tsx (1)
packages/stack-shared/src/utils/objects.tsx (2)
  • typedFromEntries (281-283)
  • typedEntries (263-265)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (1)
packages/stack-shared/src/utils/dates.tsx (1)
  • DayInterval (146-146)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (2)
packages/stack-ui/src/components/simple-tooltip.tsx (1)
  • SimpleTooltip (5-46)
docs/src/components/icons.tsx (2)
  • Search (93-96)
  • Plus (317-320)
packages/stack-ui/src/components/ui/dropdown-menu.tsx (1)
packages/stack-ui/src/index.ts (1)
  • cn (53-53)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx (2)
packages/stack-ui/src/components/simple-tooltip.tsx (1)
  • SimpleTooltip (5-46)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (2)
  • buildPriceUpdate (65-106)
  • freeTrialLabel (50-55)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (3)
packages/stack-ui/src/components/action-dialog.tsx (1)
  • ActionDialog (31-135)
packages/stack-shared/src/utils/objects.tsx (2)
  • typedFromEntries (281-283)
  • typedEntries (263-265)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (1)
  • ListSection (19-97)
apps/dashboard/src/components/payments/item-dialog.tsx (2)
apps/dashboard/src/lib/utils.tsx (1)
  • cn (7-9)
packages/stack-ui/src/components/ui/typography.tsx (1)
  • Typography (47-47)
🪛 Biome (2.1.2)
apps/dashboard/src/components/data-table/payment-product-table.tsx

[error] 108-108: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx

[error] 445-445: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: claude-review
  • GitHub Check: Agent
  • GitHub Check: CodeQL analysis (javascript-typescript)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: docker
  • GitHub Check: restart-dev-and-test
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: setup-tests
  • GitHub Check: all-good
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
🔇 Additional comments (21)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx (1)

42-44: LGTM!

The function correctly enables test mode and follows the same pattern as the existing handleDisableTestMode.

packages/stack-ui/src/components/ui/dropdown-menu.tsx (2)

118-120: Well-implemented icon prop for DropdownMenuItem.

The icon support is cleanly integrated - the conditional padding logic (inset || icon) && "pl-9" correctly handles both cases, and the absolutely positioned icon aligns well with the existing patterns used for checkbox/radio indicators.

Also applies to: 130-131, 145-149


65-66: Consistent styling updates across dropdown components.

The padding, border, and spacing changes are applied uniformly across SubTrigger, Content, CheckboxItem, RadioItem, and Label components, maintaining visual consistency.

Also applies to: 103-103, 164-164, 170-170, 188-188, 193-193, 212-213

apps/dashboard/src/components/data-table/payment-product-table.tsx (1)

53-58: LGTM!

Good defensive filtering to handle null products before mapping, preventing rendering issues during deletions.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (1)

63-98: LGTM!

The three-way freeTrial handling (null to remove, DayInterval to set, undefined to compute) is cleanly implemented and well-documented.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx (2)

148-177: LGTM!

The native button elements with proper hover/transition styling follow the coding guidelines for snappy transitions ("transition-colors duration-150 hover:transition-none").


180-222: LGTM!

The custom interval section is cleanly structured with consistent styling between light and dark modes.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (1)

111-136: LGTM!

The inline toggle button group is cleanly implemented with proper transition patterns per coding guidelines.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (2)

414-424: LGTM!

Good input sanitization for Product ID - restricting to lowercase letters, numbers, and hyphens, and clearing the error on change allows users to correct mistakes without lingering errors.


293-316: LGTM!

Template cards have clean hover states with consistent transition patterns following the coding guidelines.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx (2)

105-120: LGTM!

The type change from Partial<ReturnType<...>> to Partial<Parameters<...>[0]> is correct since we're passing input parameters to buildPriceUpdate, not its output.


246-249: LGTM!

Passing freeTrial: null to explicitly remove the free trial integrates correctly with the updated buildPriceUpdate function's three-way handling.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (2)

612-618: LGTM!

Good defensive filtering to skip null products when creating refs, ensuring consistency after deletions.


632-640: LGTM!

Proper null/undefined filtering during product grouping prevents rendering issues.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (3)

46-52: LGTM!

Good pattern - using a custom ValidationError class to distinguish validation failures (which should keep edit mode open) from other errors (which should propagate).


1863-1900: LGTM!

The deletion logic properly rebuilds the products object and handles the edge case of deleting the last product in a catalog by also removing the empty catalog.


1566-1576: LGTM!

Using ValidationError to signal validation failures without closing edit mode is clean - the throw prevents the draft from being removed while the toast provides user feedback.

apps/dashboard/src/components/payments/item-dialog.tsx (4)

4-5: Imports align with component usage

All newly added imports (Button, Typography, Package, etc.) are used in this file and match the updated UI structure; nothing appears unused or redundant.


40-41: Item ID sanitization and validation are consistent

The input sanitization (toLowerCase().replace(/[^a-z0-9_-]/g, '')), the validation regex (^[a-z0-9_-]+$), and the updated error/description copy are aligned with each other and prevent inconsistent states. The conditional error styling on the input also looks correct.

Also applies to: 115-133, 136-143


83-88: Dialog layout and header redesign look structurally sound

The new DialogContent sizing/styling and the richer header (icon, title, description) maintain proper semantics with DialogTitle/DialogDescription while matching the updated visual system. No structural or accessibility regressions stand out.

Also applies to: 89-103, 105-105


148-150: Display name field error handling and helper text are clear

The display name label, input styling, conditional error text, and muted helper text follow the same pattern as the item ID field and give good guidance without extra complexity. Error clearing on change is also handled correctly.

Also applies to: 165-171, 173-181

Comment on lines 73 to 88
{onAddClick && (
<Button
variant="ghost"
size="sm"
className={cn(
"h-9 w-9 p-0 rounded-xl",
"text-muted-foreground hover:text-foreground",
"hover:bg-foreground/[0.06] border border-transparent hover:border-border/40 dark:hover:border-foreground/[0.08]",
"transition-all duration-150 hover:transition-none"
)}
onClick={onAddClick}
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "list-section.tsx" | grep -E "products|payments"

Repository: stack-auth/stack-auth

Length of output: 164


🏁 Script executed:

cat -n apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/list-section.tsx | head -150

Repository: stack-auth/stack-auth

Length of output: 4406


🏁 Script executed:

find . -type f -name "*.tsx" -o -name "*.ts" | xargs grep -l "export.*Button" | grep -E "stackframe|stack-ui" | head -5

Repository: stack-auth/stack-auth

Length of output: 164


🏁 Script executed:

rg "ListSection" --type tsx --type ts -B 2 -A 2 | head -100

Repository: stack-auth/stack-auth

Length of output: 92


🏁 Script executed:

rg "aria-label" --type tsx --type ts -B 1 -A 1 | grep -E "Button|IconButton|add" | head -30

Repository: stack-auth/stack-auth

Length of output: 92


🏁 Script executed:

cat packages/stack-ui/src/components/ui/button.tsx

Repository: stack-auth/stack-auth

Length of output: 3521


🏁 Script executed:

cat packages/stack-ui/src/components/copy-button.tsx | head -80

Repository: stack-auth/stack-auth

Length of output: 1056


🏁 Script executed:

rg "ListSection" -A 3 -B 1 | head -100

Repository: stack-auth/stack-auth

Length of output: 11297


🏁 Script executed:

grep -A 5 "onAddClick" apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/page-client-list-view.tsx

Repository: stack-auth/stack-auth

Length of output: 436


🏁 Script executed:

grep -A 8 "ListSection" apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/product-dialog.tsx | grep -E "ListSection|title|onAddClick" | head -20

Repository: stack-auth/stack-auth

Length of output: 403


Icon-only "add" button should expose an accessible label

The visual styling and transition behavior on the add button look good and match the rest of the header. However, the button currently renders only a <Plus> icon with no text or aria-label, so assistive technologies will announce it as an unnamed "button", which is problematic for keyboard and screen-reader users.

Add an addButtonAriaLabel prop to ListSectionProps and wire it to the button:

export type ListSectionProps = {
  title: React.ReactNode,
  titleTooltip?: string,
  onAddClick?: () => void,
  addButtonAriaLabel?: string, // e.g. "Add product", "Add price", "Add item"
  children: ReactNode,
  hasTitleBorder?: boolean,
  searchValue?: string,
  onSearchChange?: (value: string) => void,
  searchPlaceholder?: string,
};

Then apply it on the button:

{onAddClick && (
  <Button
    variant="ghost"
    size="sm"
    className={cn(
      "h-9 w-9 p-0 rounded-xl",
      "text-muted-foreground hover:text-foreground",
      "hover:bg-foreground/[0.06] border border-transparent hover:border-border/40 dark:hover:border-foreground/[0.08]",
      "transition-all duration-150 hover:transition-none"
    )}
    onClick={onAddClick}
+   aria-label={addButtonAriaLabel}
  >
    <Plus className="h-4 w-4" />
  </Button>
)}

Each usage site can then provide a context-specific label ("Add product", "Add price", "Add item") while keeping the visual design clean.

🤖 Prompt for AI Agents
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
around lines 73 to 88: the icon-only add button lacks an accessible label;
update the ListSectionProps type to include addButtonAriaLabel?: string and pass
that prop to the Button as its aria-label (e.g.,
aria-label={addButtonAriaLabel}) when rendering the Plus-only button so callers
can supply context-specific labels like "Add product" or "Add price"; ensure
prop is optional and the Button receives the aria-label only when provided (or
provide a sensible default if desired).

Comment on lines +443 to +454
onClick: async () => {
if (!productToDelete) return;
const config = project.useConfig();
const updatedProducts = typedFromEntries(
typedEntries(config.payments.products)
.filter(([productId]) => productId !== productToDelete.id)
);
await project.updateConfig({ "payments.products": updatedProducts });
toast({ title: "Product deleted" });
setProductToDelete(null);
}
}}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Hook called inside event handler - violates Rules of Hooks.

project.useConfig() at line 445 is called inside an async onClick handler. This violates React's Rules of Hooks and will cause errors.

The project already has access to config at the component level. Move the config retrieval outside the callback:

 function ProductsList({
   ...
 }: ProductsListProps) {
   const stackAdminApp = useAdminApp();
   const project = stackAdminApp.useProject();
+  const config = project.useConfig();
   const [searchQuery, setSearchQuery] = useState("");
   const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
   const [productToDelete, setProductToDelete] = useState<{ id: string, displayName: string } | null>(null);
   ...
         okButton={{
           label: "Delete",
           onClick: async () => {
             if (!productToDelete) return;
-            const config = project.useConfig();
             const updatedProducts = typedFromEntries(
               typedEntries(config.payments.products)
                 .filter(([productId]) => productId !== productToDelete.id)
             );
             ...
           }
         }}

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Biome (2.1.2)

[error] 445-445: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
around lines 443 to 454, move the call to project.useConfig() out of the async
onClick handler to the component-level (above render or inside the component
body) so hooks run unconditionally; then have the onClick handler reference that
component-level config (or a derived state/const) and compute updatedProducts
and call project.updateConfig(...) from the handler; ensure you still clear
productToDelete and show the toast inside the handler and remove any hook calls
from within event callbacks.

Comment on lines +213 to +234
<DialogFooter className="pt-4 border-t border-border/30 dark:border-foreground/[0.06] gap-2">
<Button
variant="outline"
onClick={handleClose}
className={cn(
"rounded-xl px-5",
"border-border/50 dark:border-foreground/[0.1]",
"hover:bg-foreground/[0.03]",
"transition-all duration-150 hover:transition-none"
)}
>
Cancel
</Button>
<Button onClick={validateAndSave}>
<Button
onClick={validateAndSave}
className={cn(
"rounded-xl px-5",
"bg-foreground text-background",
"hover:bg-foreground/90",
"transition-all duration-150 hover:transition-none"
)}
>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's read the file around lines 213-234 to see the current state
cat -n apps/dashboard/src/components/payments/item-dialog.tsx | sed -n '200,240p'

Repository: stack-auth/stack-auth

Length of output: 1857


🏁 Script executed:

# Search for the validateAndSave function in the file
rg "validateAndSave" apps/dashboard/src/components/payments/item-dialog.tsx -B 5 -A 10

Repository: stack-auth/stack-auth

Length of output: 1357


🏁 Script executed:

# Search for runAsynchronouslyWithAlert usage to understand the pattern in this codebase
rg "runAsynchronouslyWithAlert" apps/dashboard/src/components/ -l | head -5

Repository: stack-auth/stack-auth

Length of output: 298


🏁 Script executed:

# Check what async handling utilities are available
fd -e ts -e tsx apps/dashboard/src/ | xargs rg "export.*runAsynchronously" | head -10

Repository: stack-auth/stack-auth

Length of output: 817


🏁 Script executed:

# Check how runAsynchronouslyWithAlert is imported in existing dashboard files
rg "runAsynchronouslyWithAlert" apps/dashboard/src/components/form-dialog.tsx -B 10 | grep -E "(import|from)" | head -3

Repository: stack-auth/stack-auth

Length of output: 210


🏁 Script executed:

# Get more context on usage pattern in form-dialog.tsx
rg "onClick.*runAsynchronouslyWithAlert" apps/dashboard/src/components/form-dialog.tsx -B 2 -A 2

Repository: stack-auth/stack-auth

Length of output: 47


🏁 Script executed:

# Check actual usage in form-dialog.tsx with more context
rg "onClick" apps/dashboard/src/components/form-dialog.tsx -B 2 -A 2

Repository: stack-auth/stack-auth

Length of output: 390


🏁 Script executed:

# Also check if item-dialog.tsx already imports the utility
rg "runAsynchronously" apps/dashboard/src/components/payments/item-dialog.tsx

Repository: stack-auth/stack-auth

Length of output: 47


Wrap async validateAndSave with runAsynchronouslyWithAlert to handle promise rejections

validateAndSave is an async function called directly from onClick at line 227, which will create an unhandled promise rejection if an error occurs. Per coding guidelines, wrap it with runAsynchronouslyWithAlert to surface errors via alerts:

onClick={() => runAsynchronouslyWithAlert(validateAndSave)}

Add the import from @stackframe/stack-shared/dist/utils/promises:

import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
🤖 Prompt for AI Agents
In apps/dashboard/src/components/payments/item-dialog.tsx around lines 213 to
234, the async validateAndSave is invoked directly in the Button onClick which
can cause unhandled promise rejections; import runAsynchronouslyWithAlert from
@stackframe/stack-shared/dist/utils/promises and change the onClick to call
runAsynchronouslyWithAlert(validateAndSave) (e.g. onClick={() =>
runAsynchronouslyWithAlert(validateAndSave)}) so errors are caught and surfaced
via alerts.

@cmux-agent
Copy link

cmux-agent bot commented Dec 14, 2025

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

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

♻️ Duplicate comments (10)
apps/dashboard/src/components/data-table/payment-product-table.tsx (1)

107-115: Hook called inside event handler - violates Rules of Hooks.

project.useConfig() at line 108 is called inside an async onClick handler. Hooks must be called unconditionally at the top level of a component, not inside callbacks.

Move the hook call to component level:

 function ActionsCell({ product }: { product: PaymentProduct }) {
   const [isEditOpen, setIsEditOpen] = useState(false);
   const [isDeleteOpen, setIsDeleteOpen] = useState(false);
   const stackAdminApp = useAdminApp();
   const project = stackAdminApp.useProject();
+  const config = project.useConfig();

   return (
     <>
       ...
       <ActionDialog
         ...
         okButton={{
           label: "Delete",
           onClick: async () => {
-            const config = project.useConfig();
             const updatedProducts = typedFromEntries(
               typedEntries(config.payments.products)
                 .filter(([productId]) => productId !== product.id)
             );
             await project.updateConfig({ "payments.products": updatedProducts });
             toast({ title: "Product deleted" });
           },
         }}
       />
     </>
   );
 }
apps/dashboard/src/components/payments/item-dialog.tsx (1)

226-236: Wrap async validateAndSave with runAsynchronouslyWithAlert.

This issue was flagged in a previous review. The async validateAndSave is still called directly from onClick, creating unhandled promise rejections on failure.

+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
 <Button
-  onClick={validateAndSave}
+  onClick={() => runAsynchronouslyWithAlert(validateAndSave)}
   ...
 >
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (1)

73-87: Add aria-label to the icon-only button for accessibility.

This was flagged in a previous review. The button renders only a <Plus> icon with no accessible label, making it unidentifiable for screen reader users.

Add an addButtonAriaLabel prop to ListSectionProps and apply it:

 export type ListSectionProps = {
   title: React.ReactNode,
   titleTooltip?: string,
   onAddClick?: () => void,
+  addButtonAriaLabel?: string,
   // ...
 };
 <Button
   variant="ghost"
   size="sm"
   className={...}
   onClick={onAddClick}
+  aria-label={addButtonAriaLabel}
 >
   <Plus className="h-4 w-4" />
 </Button>
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (5)

1818-1818: Simplify callback storage.

The arrow function wrapper () => onCreated is the setState updater form, which works but is unconventional for this use case. Since onCreated is never undefined in this context, store it directly.

-    setOnItemCreatedCallback(() => onCreated);
+    setOnItemCreatedCallback(onCreated);

This is clearer and more conventional.


1198-1198: Missing error handling wrapper for async onClick.

While handleSaveEdit has internal error handling for ValidationError, any other errors would be re-thrown and need to be caught by runAsynchronouslyWithAlert.

Apply this diff:

-              onClick={async () => { await handleSaveEdit(); }}
+              onClick={() => runAsynchronouslyWithAlert(handleSaveEdit)}

Don't forget to import runAsynchronouslyWithAlert from @stackframe/stack-shared/dist/utils/promises.

Based on coding guidelines and learnings.


1341-1343: Missing error handling wrapper for async onClick.

The async onClick handler needs runAsynchronouslyWithAlert to properly handle errors.

Apply this diff:

         okButton={{
           label: "Delete",
-          onClick: async () => {
+          onClick: () => runAsynchronouslyWithAlert(async () => {
             await onDelete(id);
             setIsEditing(false);
-          }
+          })
         }}

Based on coding guidelines and learnings.


1724-1728: Remove empty useEffect.

This effect has an empty body and serves no purpose. The comment suggests it's a safeguard, but re-rendering is already handled by the productsKey dependency in the useMemo above.

-  // Watch for changes in products and force re-render if needed
-  useEffect(() => {
-    // This effect will run whenever paymentsConfig.products changes
-    // The productsKey dependency in useMemo above should handle most cases,
-    // but this ensures we catch any edge cases
-  }, [productsKey]);

1706-1710: Remove unused productIds variable.

The productIds variable is computed but never used. Only productsKey (lines 1714-1721) is referenced in the code.

-  const productIds = Object.entries(paymentsConfig.products)
-    .filter(([, product]) => product != null)
-    .map(([id]) => id)
-    .sort()
-    .join(',');
-
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (2)

443-453: CRITICAL: Hook violation and missing error handling in async onClick.

This code has two critical issues:

  1. Hook called inside event handler (line 445): project.useConfig() is called inside the async onClick handler, violating React's Rules of Hooks. Hooks must be called unconditionally at the top level.

  2. Missing error handling: The async onClick handler needs runAsynchronouslyWithAlert to properly handle errors and show alerts to users.

Apply this diff to fix both issues:

 function ProductsList({
   ...
 }: ProductsListProps) {
   const stackAdminApp = useAdminApp();
   const project = stackAdminApp.useProject();
+  const config = project.useConfig();
   const [searchQuery, setSearchQuery] = useState("");
   const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
   const [productToDelete, setProductToDelete] = useState<{ id: string, displayName: string } | null>(null);
   ...
       okButton={{
         label: "Delete",
-        onClick: async () => {
+        onClick: () => runAsynchronouslyWithAlert(async () => {
           if (!productToDelete) return;
-          const config = project.useConfig();
           const updatedProducts = typedFromEntries(
             typedEntries(config.payments.products)
               .filter(([productId]) => productId !== productToDelete.id)
           );
           await project.updateConfig({ "payments.products": updatedProducts });
           toast({ title: "Product deleted" });
           setProductToDelete(null);
-        }
+        })
       }}

Based on coding guidelines and learnings.


580-585: Missing error handling in async onClick.

The async onClick handler needs runAsynchronouslyWithAlert to properly handle errors and show alerts to users.

Apply this diff:

       okButton={{
         label: "Delete",
-        onClick: async () => {
+        onClick: () => runAsynchronouslyWithAlert(async () => {
           if (!itemToDelete) return;
           await project.updateConfig({ [`payments.items.${itemToDelete.id}`]: null });
           toast({ title: "Item deleted" });
           setItemToDelete(null);
-        }
+        })
       }}

Based on coding guidelines and learnings.

🧹 Nitpick comments (4)
apps/dashboard/src/components/code-block.tsx (1)

62-87: Avoid nested scrollbars + clarify “wrap vs horizontal scroll” behavior.

Right now the outer overflow-x-auto (Line 62) + inner overflow: 'auto' (Line 74) can produce nested/duplicated scrollbars, and whiteSpace: 'pre-wrap' + wordBreak: 'break-word' (Lines 75-76) + wrapLongLines (Line 83) may make horizontal scrolling effectively unreachable (everything wraps). Consider delegating horizontal scrolling to the wrapper and keeping vertical scrolling on the highlighter:

           customStyle={{
             background: 'transparent',
             padding: '1em',
             border: 0,
             boxShadow: 'none',
             margin: 0,
             fontSize: '0.875rem',
             maxHeight: props.maxHeight,
-            overflow: 'auto',
+            overflowY: 'auto',
+            overflowX: 'visible',
             whiteSpace: 'pre-wrap',
             wordBreak: 'break-word',
             ...(props.compact && {
               padding: '0.75em',
               fontSize: '0.75rem',
             }),
           }}

Please double-check react-syntax-highlighter@15.6.1 semantics: whether wrapLongLines already applies the whiteSpace/wordBreak styles (making Lines 75-76 redundant), and whether the desired UX is “wrap by default” or “preserve horizontal scroll for code fidelity.”

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx (2)

140-145: Optional: Redundant pointer-events-none class.

The pointer-events-none class applied when readOnly is true is redundant since the disabled prop on the PopoverTrigger (which forwards to the button via asChild) already prevents user interaction. While harmless, you can simplify the conditional class application.

-        <button type="button" className={cn(triggerClasses, readOnly && "cursor-default pointer-events-none")}>
+        <button type="button" className={cn(triggerClasses, readOnly && "cursor-default")}>

184-199: Strengthen input validation to handle edge cases.

The current validation at line 195 doesn't explicitly check for NaN, which can occur when the input is cleared or contains non-numeric characters. While the > 0 check implicitly prevents NaN from being processed (since NaN > 0 is false), the code would be more robust and maintainable with an explicit check.

             <Input
               type="number"
               min={1}
               className={cn(
                 "w-20 h-9 text-sm",
                 "rounded-lg border border-border/60 dark:border-foreground/[0.1]",
                 "bg-background dark:bg-[hsl(240,10%,10%)]"
               )}
               value={effectiveSelection === 'custom' ? count : 1}
               onChange={(e) => {
                 const val = parseInt(e.target.value, 10);
-                if (val > 0) {
+                if (!isNaN(val) && val > 0) {
                     applyCustom(val, effectiveUnit);
                 }
               }}
             />
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (1)

128-144: Extract duplicated LabelWithInfo component to shared location.

The LabelWithInfo component is duplicated between this file and product-price-row.tsx. Extract it to a shared location to follow DRY principles.

Consider moving it to a shared components directory:

// apps/dashboard/src/components/label-with-info.tsx
import { Label, SimpleTooltip } from "@stackframe/stack-ui";
import { Info } from "lucide-react";

export function LabelWithInfo({ children, tooltip }: { children: React.ReactNode, tooltip?: string }) {
  return (
    <div className="flex items-center gap-1.5">
      <Label className="text-[11px] font-medium uppercase tracking-[0.24em] text-muted-foreground">
        {children}
      </Label>
      {tooltip && (
        <SimpleTooltip tooltip={tooltip}>
          <Info className="h-3 w-3 text-muted-foreground/60 cursor-help" />
        </SimpleTooltip>
      )}
    </div>
  );
}

Then import from both files.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dcfab53 and 3293d98.

📒 Files selected for processing (13)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx (2 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (2 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (30 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (16 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (10 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx (12 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (4 hunks)
  • apps/dashboard/src/components/code-block.tsx (2 hunks)
  • apps/dashboard/src/components/data-table/payment-product-table.tsx (3 hunks)
  • apps/dashboard/src/components/payments/item-dialog.tsx (5 hunks)
  • packages/stack-ui/src/components/ui/dropdown-menu.tsx (10 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
  • packages/stack-ui/src/components/ui/dropdown-menu.tsx
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Always add new E2E tests when changing the API or SDK interface
For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling
Use ES6 maps instead of records wherever you can

Files:

  • apps/dashboard/src/components/code-block.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,css}: Keep hover/click transitions snappy and fast; avoid fade-in delays on hover. Apply transitions after action completion instead, like smooth fade-out when hover ends
Use hover-exit transitions instead of hover-enter transitions; for example, use 'transition-colors hover:transition-none' instead of fade-in on hover

Files:

  • apps/dashboard/src/components/code-block.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
apps/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Files:

  • apps/dashboard/src/components/code-block.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
{.env*,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (AGENTS.md)

Prefix environment variables with STACK_ (or NEXT_PUBLIC_STACK_ if public) so changes are picked up by Turborepo and improves readability

Files:

  • apps/dashboard/src/components/code-block.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
apps/dashboard/**/*

📄 CodeRabbit inference engine (AGENTS.md)

When making changes in the dashboard, provide the user with a deep link to the dashboard page changed, usually in the form of http://localhost:<NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/... or using a.localhost, b.localhost, c.localhost for port prefixes 91, 92, 93

Files:

  • apps/dashboard/src/components/code-block.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
apps/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Files:

  • apps/dashboard/src/components/code-block.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
🧠 Learnings (5)
📚 Learning: 2025-10-11T04:13:19.308Z
Learnt from: N2D4
Repo: stack-auth/stack-auth PR: 943
File: examples/convex/app/action/page.tsx:23-28
Timestamp: 2025-10-11T04:13:19.308Z
Learning: In the stack-auth codebase, use `runAsynchronouslyWithAlert` from `stackframe/stack-shared/dist/utils/promises` for async button click handlers and form submissions instead of manual try/catch blocks. This utility automatically handles errors and shows alerts to users.

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/components/payments/item-dialog.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : Always add new E2E tests when changing the API or SDK interface

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/**/*.tsx : Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Applied to files:

  • apps/dashboard/src/components/data-table/payment-product-table.tsx
🧬 Code graph analysis (7)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (2)
packages/stack-ui/src/components/simple-tooltip.tsx (1)
  • SimpleTooltip (5-46)
docs/src/components/icons.tsx (2)
  • Search (93-96)
  • Plus (317-320)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (1)
packages/stack-shared/src/utils/dates.tsx (1)
  • DayInterval (146-146)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx (3)
apps/e2e/tests/js/convex.test.ts (1)
  • open (31-33)
docs/src/components/icons.tsx (1)
  • ChevronsUpDown (88-91)
packages/stack-shared/src/utils/dates.tsx (1)
  • DayInterval (146-146)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (3)
packages/stack-ui/src/components/action-dialog.tsx (1)
  • ActionDialog (31-135)
packages/stack-shared/src/utils/objects.tsx (2)
  • typedFromEntries (281-283)
  • typedEntries (263-265)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (1)
  • ListSection (19-97)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (6)
  • Product (8-8)
  • getPricesObject (111-121)
  • intervalLabel (26-34)
  • Price (9-9)
  • PricesObject (10-10)
  • shortIntervalLabel (39-45)
packages/stack-shared/src/utils/numbers.tsx (1)
  • prettyPrintWithMagnitudes (9-26)
packages/stack-shared/src/utils/objects.tsx (2)
  • typedEntries (263-265)
  • typedFromEntries (281-283)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx (2)
packages/stack-ui/src/components/simple-tooltip.tsx (1)
  • SimpleTooltip (5-46)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (2)
  • buildPriceUpdate (65-106)
  • freeTrialLabel (50-55)
apps/dashboard/src/components/data-table/payment-product-table.tsx (1)
packages/stack-shared/src/utils/objects.tsx (2)
  • typedFromEntries (281-283)
  • typedEntries (263-265)
🪛 Biome (2.1.2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx

[error] 445-445: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

apps/dashboard/src/components/data-table/payment-product-table.tsx

[error] 108-108: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: setup-tests
  • GitHub Check: restart-dev-and-test
  • GitHub Check: all-good
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: docker
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: build (22.x)
  • GitHub Check: lint_and_build (latest)
🔇 Additional comments (10)
apps/dashboard/src/components/data-table/payment-product-table.tsx (1)

53-58: LGTM!

Good defensive approach to filter out null products before mapping. This correctly handles the case where products may be nullified during deletion.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx (1)

25-33: LGTM! Clean styling refactor.

The replacement of the Separator component with native div elements maintains the same visual appearance while reducing dependencies. The styling is consistent and appropriate.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (1)

85-98: LGTM! Clear three-way logic for freeTrial handling.

The explicit null/DayInterval/undefined branching cleanly separates removal, direct assignment, and computed defaults. The JSDoc at line 63 documents this well.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx (1)

88-147: Good use of hover-exit transition pattern.

The transition-colors duration-150 hover:transition-none pattern at line 140 correctly follows the coding guidelines for snappy hover interactions with smooth fade-out.

apps/dashboard/src/components/payments/item-dialog.tsx (1)

40-41: LGTM! Validation and sanitization regexes are aligned.

Both the validation (/^[a-z0-9_-]+$/) and sanitization (/[^a-z0-9_-]/g) patterns now consistently allow underscores, and the error message accurately reflects this.

Also applies to: 115-116

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (1)

111-136: LGTM! Clean view toggle implementation.

The inline button group uses accessible text labels and follows the hover-exit transition pattern correctly. Synchronous setView handlers don't require async error handling.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (1)

56-70: LGTM! Well-structured search input with proper styling.

The search input follows the hover-exit transition pattern and maintains consistent styling with other inputs in this PR.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-price-row.tsx (2)

19-35: LGTM! Well-structured helper component.

The LabelWithInfo component provides a clean abstraction for labels with optional info tooltips. The implementation correctly uses SimpleTooltip and follows React best practices.


183-188: Direct parameter passing in onChange is correct.

The code correctly passes the new value directly to savePriceUpdate since setState is async. The guard in the useEffect at line 89 (if (isEditing) return;) prevents external updates from interfering during editing.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx (1)

1863-1900: LGTM! Proper deletion handling with catalog cleanup.

The function correctly:

  • Uses typedFromEntries to rebuild the products object without the deleted product
  • Cleans up empty catalogs when deleting the last product
  • Forces a re-render with refreshKey to ensure UI consistency

This is the correct pattern for deletions.

Comment on lines +149 to 177
<button
className={cn(
"flex items-center w-full px-3 py-2 rounded-lg text-left text-sm font-medium",
"transition-colors duration-150 hover:transition-none",
effectiveSelection === 'one-time'
? "bg-foreground/[0.08] text-foreground"
: "hover:bg-foreground/[0.04] text-foreground"
)}
onClick={selectOneTime}
>
{noneLabel}
</Button>
</button>

{/* Fixed interval options */}
{normalizedUnits.map((unitOption) => (
<Button
<button
key={unitOption}
variant={effectiveSelection === unitOption ? 'secondary' : 'ghost'}
size="sm"
className="justify-start"
className={cn(
"flex items-center w-full px-3 py-2 rounded-lg text-left text-sm font-medium",
"transition-colors duration-150 hover:transition-none",
effectiveSelection === unitOption
? "bg-foreground/[0.08] text-foreground"
: "hover:bg-foreground/[0.04] text-foreground"
)}
onClick={() => selectFixed(unitOption)}
>
{buttonLabels[unitOption]}
</Button>
</button>
))}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Add type="button" to prevent unintended form submissions.

The one-time and fixed interval buttons are missing the type="button" attribute. When used inside a form context, these buttons will default to type="submit" and could trigger form submission instead of just selecting an interval option.

Apply this diff to fix the issue:

           <button
+            type="button"
             className={cn(
               "flex items-center w-full px-3 py-2 rounded-lg text-left text-sm font-medium",
               "transition-colors duration-150 hover:transition-none",
               effectiveSelection === 'one-time'
                 ? "bg-foreground/[0.08] text-foreground"
                 : "hover:bg-foreground/[0.04] text-foreground"
             )}
             onClick={selectOneTime}
           >
             {noneLabel}
           </button>

           {/* Fixed interval options */}
           {normalizedUnits.map((unitOption) => (
             <button
               key={unitOption}
+              type="button"
               className={cn(
                 "flex items-center w-full px-3 py-2 rounded-lg text-left text-sm font-medium",
                 "transition-colors duration-150 hover:transition-none",
                 effectiveSelection === unitOption
                   ? "bg-foreground/[0.08] text-foreground"
                   : "hover:bg-foreground/[0.04] text-foreground"
               )}
               onClick={() => selectFixed(unitOption)}
             >
               {buttonLabels[unitOption]}
             </button>
           ))}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
className={cn(
"flex items-center w-full px-3 py-2 rounded-lg text-left text-sm font-medium",
"transition-colors duration-150 hover:transition-none",
effectiveSelection === 'one-time'
? "bg-foreground/[0.08] text-foreground"
: "hover:bg-foreground/[0.04] text-foreground"
)}
onClick={selectOneTime}
>
{noneLabel}
</Button>
</button>
{/* Fixed interval options */}
{normalizedUnits.map((unitOption) => (
<Button
<button
key={unitOption}
variant={effectiveSelection === unitOption ? 'secondary' : 'ghost'}
size="sm"
className="justify-start"
className={cn(
"flex items-center w-full px-3 py-2 rounded-lg text-left text-sm font-medium",
"transition-colors duration-150 hover:transition-none",
effectiveSelection === unitOption
? "bg-foreground/[0.08] text-foreground"
: "hover:bg-foreground/[0.04] text-foreground"
)}
onClick={() => selectFixed(unitOption)}
>
{buttonLabels[unitOption]}
</Button>
</button>
))}
<button
type="button"
className={cn(
"flex items-center w-full px-3 py-2 rounded-lg text-left text-sm font-medium",
"transition-colors duration-150 hover:transition-none",
effectiveSelection === 'one-time'
? "bg-foreground/[0.08] text-foreground"
: "hover:bg-foreground/[0.04] text-foreground"
)}
onClick={selectOneTime}
>
{noneLabel}
</button>
{/* Fixed interval options */}
{normalizedUnits.map((unitOption) => (
<button
type="button"
key={unitOption}
className={cn(
"flex items-center w-full px-3 py-2 rounded-lg text-left text-sm font-medium",
"transition-colors duration-150 hover:transition-none",
effectiveSelection === unitOption
? "bg-foreground/[0.08] text-foreground"
: "hover:bg-foreground/[0.04] text-foreground"
)}
onClick={() => selectFixed(unitOption)}
>
{buttonLabels[unitOption]}
</button>
))}
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/components.tsx
around lines 149 to 177, the one-time button and each fixed-interval button lack
an explicit type attribute and will default to type="submit" inside a form;
update both the standalone one-time <button> and the mapped buttons to include
type="button" to prevent accidental form submissions, ensuring each button
element has type="button" while leaving existing props (className, onClick,
children) unchanged.

Comment on lines +131 to +144
<Button
size="sm"
variant="outline"
onClick={() => handleDisableTestMode()}
className={cn(
"shrink-0 text-xs font-medium",
"border-blue-500/30 dark:border-blue-400/30",
"text-blue-700 dark:text-blue-300",
"hover:bg-blue-500/10 dark:hover:bg-blue-400/10",
"transition-colors duration-150 hover:transition-none"
)}
>
Disable test mode
</Button>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Wrap async handlers with runAsynchronouslyWithAlert to handle rejections.

The handleDisableTestMode function is async but called directly from onClick, creating an unhandled promise rejection if it fails. The same issue exists for handleEnableTestMode (line 187) and setupPayments (line 201).

Per coding guidelines, wrap these with runAsynchronouslyWithAlert:

+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
 <Button
   size="sm"
   variant="outline"
-  onClick={() => handleDisableTestMode()}
+  onClick={() => runAsynchronouslyWithAlert(handleDisableTestMode)}
   ...
 >

Apply the same pattern to the other async handlers at lines 187 and 201.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
around lines 131–144 (and also update the similar cases at line 187 and line
201), the async onclick handlers are invoked directly which can cause unhandled
promise rejections; wrap the calls in runAsynchronouslyWithAlert so the async
function is executed through that helper (e.g., replace onClick={() =>
handleDisableTestMode()} with onClick={() => runAsynchronouslyWithAlert(() =>
handleDisableTestMode())}) and apply the same wrapping for handleEnableTestMode
at line 187 and setupPayments at line 201 to ensure rejections are caught and
surfaced via alerts.

import type { DayInterval } from "@stackframe/stack-shared/dist/utils/dates";
import { prettyPrintWithMagnitudes } from "@stackframe/stack-shared/dist/utils/numbers";
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects";
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Add missing import for error handling utility.

The fixes for error handling require importing runAsynchronouslyWithAlert. Add it to your imports:

 import { typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects";
+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-catalogs-view.tsx
around line 10, the code imports typedEntries and typedFromEntries but is
missing the runAsynchronouslyWithAlert utility needed for the new error
handling; add a new named import for runAsynchronouslyWithAlert from
"@stackframe/stack-shared/dist/utils/errors" (or the appropriate errors util
module used in the repo) and use that import in the error handling fixes.

Copy link

@vercel vercel bot left a comment

Choose a reason for hiding this comment

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

Additional Suggestion:

The items list crashes when trying to sort after an item is deleted, because the deletion sets items to null but the sort function tries to access properties on null values.

View Details
📝 Patch Details
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
index f6a96b47..0064a6cc 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
@@ -492,15 +492,17 @@ function ItemsList({
   // Sort items by customer type, then by ID
   const sortedItems = useMemo(() => {
     const customerTypePriority = { user: 1, team: 2, custom: 3 };
-    return Object.entries(items).sort(([aId, aItem]: [string, any], [bId, bItem]: [string, any]) => {
-      const priorityA = customerTypePriority[aItem.customerType as keyof typeof customerTypePriority] || 4;
-      const priorityB = customerTypePriority[bItem.customerType as keyof typeof customerTypePriority] || 4;
-      if (priorityA !== priorityB) {
-        return priorityA - priorityB;
-      }
-      // If same customer type, sort by ID
-      return stringCompare(aId, bId);
-    });
+    return Object.entries(items)
+      .filter(([, item]) => item != null)
+      .sort(([aId, aItem]: [string, any], [bId, bItem]: [string, any]) => {
+        const priorityA = customerTypePriority[aItem.customerType as keyof typeof customerTypePriority] || 4;
+        const priorityB = customerTypePriority[bItem.customerType as keyof typeof customerTypePriority] || 4;
+        if (priorityA !== priorityB) {
+          return priorityA - priorityB;
+        }
+        // If same customer type, sort by ID
+        return stringCompare(aId, bId);
+      });
   }, [items]);
 
   // Filter items based on search query
@@ -619,8 +621,9 @@ export default function PageClient() {
 
   const itemRefs = useMemo(() => {
     const refs = Object.fromEntries(
-      Object.keys(paymentsConfig.items)
-        .map(id => [id, React.createRef<HTMLDivElement>()])
+      Object.entries(paymentsConfig.items)
+        .filter(([, item]) => item != null)
+        .map(([id]) => [id, React.createRef<HTMLDivElement>()])
     );
     return refs;
   }, [paymentsConfig.items]);

Analysis

TypeError when sorting items after deletion

What fails: ItemsList component in page-client-list-view.tsx crashes when rendering after an item is deleted, because the sortedItems useMemo tries to access customerType property on null values.

How to reproduce:

  1. Navigate to the payments/products page
  2. Create an item (has a customerType property)
  3. Delete the item (sets it to null in the config via updateConfig({ ['payments.items.{id}']: null }))
  4. Observe the component re-render

Result: TypeError: Cannot read properties of null (reading 'customerType') at line 495 where the sort function accesses aItem.customerType on a null item

Expected: Items list should gracefully handle and skip null items during sorting, consistent with how the products sorting code handles deleted products

Root cause: The sortedItems useMemo at lines 492-504 did not filter out null items before sorting, unlike the similar productRefs code at line 614 which correctly filters with .filter(([, product]) => product != null)

Fix applied:

  • Added .filter(([, item]) => item != null) after Object.entries(items) in the sortedItems useMemo (line 495)
  • Applied the same null filter to itemRefs useMemo (line 623) for consistency with productRefs pattern

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 (12)
apps/dashboard/src/components/editable-grid.tsx (2)

136-144: Missing error handling in handleChange swallows failures.

The try/finally block clears isUpdating but silently swallows any error thrown by onUpdate. Per coding guidelines and past review feedback, use runAsynchronouslyWithAlert or re-throw errors after cleanup so failures are surfaced to the user.

Based on learnings, use runAsynchronouslyWithAlert from stackframe/stack-shared/dist/utils/promises for async handlers.


195-203: Missing error handling in handleChange swallows failures.

Same issue as EditableBooleanField: the try/finally block clears isUpdating but silently swallows errors from onUpdate. Use runAsynchronouslyWithAlert or re-throw after cleanup.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (10)

97-114: Missing null check for catalog reference.

Line 99 accesses config.payments.catalogs[catalogId] without verifying the catalog exists. If the catalog was deleted but the reference remains, accessing .displayName will throw a TypeError.

Add a null check or fallback:

-  const catalogName = catalogId ? config.payments.catalogs[catalogId].displayName || catalogId : null;
+  const catalogName = catalogId ? config.payments.catalogs[catalogId]?.displayName || catalogId : null;

129-138: Missing null check for parent product reference.

Line 136 accesses config.payments.products[parentId].displayName without verifying the parent product exists. If a parent product was deleted but remains in isAddOnTo, this will throw a TypeError.

Add optional chaining:

-      displayName: config.payments.products[parentId].displayName || parentId,
+      displayName: config.payments.products[parentId]?.displayName || parentId,

277-283: Missing null check for parent product reference.

Line 281 accesses config.payments.products[parentId].displayName without verifying the parent product exists, identical to the issue in ProductHeader.

Add optional chaining:

-      displayName: config.payments.products[parentId].displayName || parentId,
+      displayName: config.payments.products[parentId]?.displayName || parentId,

354-358: Use alert instead of toast for blocking validation errors.

Per coding guidelines, blocking validation errors should use alerts instead of toasts as they are less easily missed by the user. Replace the toast with an alert.

Based on learnings, for blocking alerts and errors, never use toast; use alerts instead.


452-508: Free trial popover doesn't sync state when opening.

When the popover opens, freeTrialCount and freeTrialUnit state (lines 273-274) are never synced from the product's current freeTrial value. The popover will always show the hardcoded defaults (7 days) instead of the current free trial configuration.

Add an onOpenChange handler that syncs state when opening:

-  <Popover open={freeTrialPopoverOpen} onOpenChange={setFreeTrialPopoverOpen}>
+  <Popover open={freeTrialPopoverOpen} onOpenChange={(open) => {
+    if (open && freeTrialInfo) {
+      setFreeTrialCount(freeTrialInfo[0]);
+      setFreeTrialUnit(freeTrialInfo[1]);
+    }
+    setFreeTrialPopoverOpen(open);
+  }}>

678-696: Missing error handling in handleDeletePrice swallows failures.

The try/finally block clears deletingPriceIds but silently swallows any error from project.updateConfig. Use runAsynchronouslyWithAlert or re-throw errors after cleanup.


873-882: Missing delete confirmation for prices.

Clicking the delete button immediately deletes the price without confirmation. Add a confirmation dialog to prevent accidental deletions.


1160-1178: Missing error handling in handleDeleteItem swallows failures.

Same try/finally pattern as handleDeletePrice: errors from project.updateConfig are silently swallowed. Use runAsynchronouslyWithAlert or re-throw errors.


1244-1246: Missing null check for item reference.

Line 1246 accesses config.payments.items[itemId].displayName without verifying the item exists. If an item was deleted but remains in includedItems, this will throw a TypeError.

Add optional chaining:

-            const displayName = itemConfig.displayName || itemId;
+            const displayName = itemConfig?.displayName || itemId;

1507-1507: Hard limit of 100 transactions may hide customers.

The hard limit of 100 transactions means customers may be hidden if the product has more than 100 purchase transactions. Consider implementing pagination or displaying a warning when the limit is reached.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c057e06 and 507d641.

📒 Files selected for processing (4)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (1 hunks)
  • apps/dashboard/src/components/editable-grid.tsx (1 hunks)
  • packages/stack-ui/src/components/ui/input.tsx (2 hunks)
  • packages/stack-ui/src/components/ui/select.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Always add new E2E tests when changing the API or SDK interface
For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling
Use ES6 maps instead of records wherever you can

Files:

  • packages/stack-ui/src/components/ui/select.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • packages/stack-ui/src/components/ui/input.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,css}: Keep hover/click transitions snappy and fast; avoid fade-in delays on hover. Apply transitions after action completion instead, like smooth fade-out when hover ends
Use hover-exit transitions instead of hover-enter transitions; for example, use 'transition-colors hover:transition-none' instead of fade-in on hover

Files:

  • packages/stack-ui/src/components/ui/select.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • packages/stack-ui/src/components/ui/input.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
{.env*,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (AGENTS.md)

Prefix environment variables with STACK_ (or NEXT_PUBLIC_STACK_ if public) so changes are picked up by Turborepo and improves readability

Files:

  • packages/stack-ui/src/components/ui/select.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • packages/stack-ui/src/components/ui/input.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
apps/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Files:

  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
apps/dashboard/**/*

📄 CodeRabbit inference engine (AGENTS.md)

When making changes in the dashboard, provide the user with a deep link to the dashboard page changed, usually in the form of http://localhost:<NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/... or using a.localhost, b.localhost, c.localhost for port prefixes 91, 92, 93

Files:

  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
apps/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Files:

  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
🧠 Learnings (3)
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling

Applied to files:

  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
📚 Learning: 2025-10-11T04:13:19.308Z
Learnt from: N2D4
Repo: stack-auth/stack-auth PR: 943
File: examples/convex/app/action/page.tsx:23-28
Timestamp: 2025-10-11T04:13:19.308Z
Learning: In the stack-auth codebase, use `runAsynchronouslyWithAlert` from `stackframe/stack-shared/dist/utils/promises` for async button click handlers and form submissions instead of manual try/catch blocks. This utility automatically handles errors and shows alerts to users.

Applied to files:

  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
🧬 Code graph analysis (2)
apps/dashboard/src/components/editable-grid.tsx (4)
apps/e2e/tests/js/convex.test.ts (1)
  • open (31-33)
packages/stack-ui/src/components/simple-tooltip.tsx (1)
  • SimpleTooltip (5-46)
packages/stack-ui/src/components/ui/select.tsx (5)
  • Select (160-160)
  • SelectTrigger (160-160)
  • SelectValue (160-160)
  • SelectContent (160-160)
  • SelectItem (160-160)
apps/dashboard/src/components/editable-input.tsx (1)
  • EditableInput (20-145)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (6)
packages/stack-ui/src/components/ui/typography.tsx (1)
  • Typography (47-47)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (6)
  • Product (8-8)
  • DEFAULT_INTERVAL_UNITS (16-16)
  • generateUniqueId (139-141)
  • intervalLabel (26-34)
  • PRICE_INTERVAL_UNITS (17-17)
  • shortIntervalLabel (39-45)
apps/dashboard/src/components/editable-grid.tsx (2)
  • EditableGridItem (86-92)
  • EditableGrid (383-399)
packages/stack-shared/src/utils/objects.tsx (1)
  • typedEntries (263-265)
apps/dashboard/src/components/repeating-input.tsx (1)
  • RepeatingInput (79-278)
packages/stack-shared/src/utils/numbers.tsx (1)
  • prettyPrintWithMagnitudes (9-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: Cursor Bugbot
  • GitHub Check: build (22.x)
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: all-good
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: setup-tests
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: restart-dev-and-test-with-custom-base-port
🔇 Additional comments (8)
packages/stack-ui/src/components/ui/select.tsx (1)

28-28: LGTM! Styling simplification improves performance.

Removing backdrop-blur-md and the semi-transparent backgrounds simplifies the visual chrome while improving render performance, as backdrop filters are computationally expensive. The component retains all essential styling (borders, spacing, focus states) and functionality.

packages/stack-ui/src/components/ui/input.tsx (1)

16-16: LGTM! Visual styling simplification aligns with redesign objectives.

The removal of backdrop-blur-md and background color classes simplifies the input styling while maintaining the necessary flex layout structure. The wrapper divs are retained appropriately to ensure flex-1 expansion behavior works correctly.

Also applies to: 30-30

apps/dashboard/src/components/editable-grid.tsx (3)

101-118: LGTM!

The GridLabel, CustomButtonField, and CustomDropdownField components are straightforward presentational components with appropriate styling and accessibility considerations.

Also applies to: 274-322


325-381: LGTM!

The GridItemValue component provides a clean type-safe routing mechanism for rendering different grid item types.


383-399: LGTM!

The EditableGrid component provides a clean, responsive two-column layout with appropriate grid configuration.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (3)

66-89: LGTM!

The PageClient component appropriately handles the not-found case with a clear UI and navigation back to the products list.


1596-1700: LGTM!

The customer display components (CustomerRow, UserCell, TeamCell) appropriately handle loading states and provide clear navigation links.


540-614: LGTM!

The dialog components provide clear UX for editing add-ons, prices, and items with appropriate form controls and validation hints.

Also applies to: 915-1083, 1311-1437

Comment on lines 647 to 676
const handleSavePrice = async (editing: EditingPrice, isNew: boolean) => {
const interval: DayInterval | undefined = editing.intervalSelection === 'one-time'
? undefined
: [editing.intervalCount, editing.priceInterval || 'month'];

const freeTrial: DayInterval | undefined = editing.freeTrialEnabled
? [editing.freeTrialCount, editing.freeTrialUnit]
: undefined;

const newPrice: Price = {
USD: editing.amount,
serverOnly: !!editing.serverOnly,
...(interval && { interval }),
...(freeTrial && { freeTrial }),
};

const currentPrices = typeof prices === 'object' && prices !== null ? prices : {};
const updatedPrices = {
...currentPrices,
[editing.priceId]: newPrice,
};

await project.updateConfig({
[`payments.products.${productId}.prices`]: updatedPrices,
});

toast({ title: isNew ? "Price added" : "Price updated" });
setEditingPrice(null);
setIsAddingPrice(false);
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing error handling in multiple async handlers.

Multiple async functions (handleSavePrice, handleAddPrices, handleSetIncludeByDefault, handleSetFreeNotIncluded, handleDisplayNameUpdate, handleCatalogUpdate, handleCreateCatalog, handleStackableUpdate, handleServerOnlyUpdate, handleAddOnSave, handleFreeTrialSave, handleRemoveFreeTrial) lack error handling. If any project.updateConfig call fails, the error will be unhandled.

Per coding guidelines and learnings, these handlers should use runAsynchronouslyWithAlert from stackframe/stack-shared/dist/utils/promises to automatically handle errors and show alerts to users.

Based on learnings, use runAsynchronouslyWithAlert for async button click handlers and form submissions.

Also applies to: 736-760, 312-385

🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
around lines 647-676 (and also apply same changes at 736-760 and 312-385), the
async handlers (handleSavePrice and the other handlers listed in the comment)
currently call project.updateConfig directly and lack error handling; update
each async click/form handler to be invoked via runAsynchronouslyWithAlert from
stackframe/stack-shared/dist/utils/promises (import it at the top of the file),
i.e. wrap the existing async body in a function passed to
runAsynchronouslyWithAlert so any thrown error is caught and an alert shown,
keep existing success toast/state updates inside the wrapped function, and
ensure the import and usage follow project conventions for other handlers.

setEditingItem(null);
setIsAddingItem(false);
setSelectedItemId('');
};
Copy link

Choose a reason for hiding this comment

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

Bug: Included item repeat uses incompatible sentinel value

handleSaveItem writes repeat: 'once' for non-repeating items, but other payments code (e.g., shortIntervalLabel) models non-repeating as 'never'. Persisting 'once' can produce incorrect interval rendering and inconsistent config state for included items.

Fix in Cursor Fix in Web

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Bug: Disabled email sending permanently marks outbox finished

When STACK_EMAIL_BRANCHING_DISABLE_QUEUE_SENDING is enabled, the code returns a Result.error and then updates the row with finishedSendingAt. This causes queued emails to be treated as completed failures and never retried/sent later when the flag is turned off, which conflicts with “disable sending” semantics.

apps/backend/src/lib/email-queue-step.tsx#L499-L530

const result = getEnvBoolean("STACK_EMAIL_BRANCHING_DISABLE_QUEUE_SENDING")
? Result.error({ errorType: "email-sending-disabled", canRetry: false, message: "Email sending is disabled", rawError: new Error("Email sending is disabled") })
: await lowLevelSendEmailDirectViaProvider({
tenancyId: context.tenancy.id,
emailConfig: context.emailConfig,
to: resolution.emails,
subject: row.renderedSubject ?? "",
html: row.renderedHtml ?? undefined,
text: row.renderedText ?? undefined,
shouldSkipDeliverabilityCheck: row.shouldSkipDeliverabilityCheck,
});
if (result.status === "error") {
await globalPrismaClient.emailOutbox.update({
where: {
tenancyId_id: {
tenancyId: row.tenancyId,
id: row.id,
},
finishedSendingAt: null,
},
data: {
finishedSendingAt: new Date(),
canHaveDeliveryInfo: false,
sendServerErrorExternalMessage: result.error.message,
sendServerErrorExternalDetails: { errorType: result.error.errorType },
sendServerErrorInternalMessage: result.error.message,
sendServerErrorInternalDetails: { rawError: errorToNiceString(result.error.rawError), errorType: result.error.errorType },
},
});
} else {

Fix in Cursor Fix in Web


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: 5

♻️ Duplicate comments (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (1)

377-377: Verify product ID character restrictions.

The input filter at line 377 removes underscores (/[^a-z0-9-]/g), but the validation pattern at line 120 is /^[a-z0-9-]+$/. According to a past review comment, items allow underscores (seen in item-dialog.tsx). This creates an inconsistency between product and item ID formats.

Consider either:

  1. Allowing underscores in product IDs to match items: .replace(/[^a-z0-9_-]/g, '')
  2. Or documenting why products restrict underscores while items allow them
#!/bin/bash
# Check if item IDs allow underscores in the codebase
rg -n "item.*id.*pattern|item.*validation" -A 3 -B 3 --type ts --type tsx
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (2)

447-458: React hook called inside event handler.

Line 449 calls project.useConfig() inside an async onClick handler, violating React's Rules of Hooks. Additionally, per coding guidelines, async handlers should use runAsynchronouslyWithAlert for proper error handling.

Move the config call to component level and wrap the handler:

 function ProductsList({
   ...
 }: ProductsListProps) {
   const stackAdminApp = useAdminApp();
   const project = stackAdminApp.useProject();
+  const config = project.useConfig();
   const projectId = useProjectId();
   const router = useRouter();
   ...
         okButton={{
           label: "Delete",
-          onClick: async () => {
+          onClick: () => runAsynchronouslyWithAlert(async () => {
             if (!productToDelete) return;
-            const config = project.useConfig();
             const updatedProducts = typedFromEntries(
               typedEntries(config.payments.products)
                 .filter(([productId]) => productId !== productToDelete.id)
             );
             await project.updateConfig({ "payments.products": updatedProducts });
             toast({ title: "Product deleted" });
             setProductToDelete(null);
-          }
+          })
         }}

Based on learnings, use runAsynchronouslyWithAlert from @stackframe/stack-shared/dist/utils/promises for async button handlers.


584-590: React hook pattern issue and missing error handling.

Similar to the product deletion handler, this should use runAsynchronouslyWithAlert for proper error handling.

Apply this fix:

           onClick: () => runAsynchronouslyWithAlert(async () => {
             if (!itemToDelete) return;
             await project.updateConfig({ [`payments.items.${itemToDelete.id}`]: null });
             toast({ title: "Item deleted" });
             setItemToDelete(null);
           })

Import the utility:

+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";

Based on learnings, async handlers should use runAsynchronouslyWithAlert instead of raw async functions.

🧹 Nitpick comments (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx (2)

217-234: Extract responsive breakpoint to a named constant.

The magic number 900 for the preview width threshold reduces readability.

Apply this diff:

+const PREVIEW_MIN_WIDTH = 900;
+
 export default function PageClient() {
   // ... 
   
   useLayoutEffect(() => {
     const container = mainContentRef.current;
     if (!container) return;

     const updateShowPreview = () => {
-      setShowPreview(container.offsetWidth > 900);
+      setShowPreview(container.offsetWidth > PREVIEW_MIN_WIDTH);
     };

237-251: Consider memoizing computed product and item lists.

existingProducts and existingItems are recomputed on every render. While the performance impact is likely minimal for small datasets, memoizing these values follows best practices and prevents unnecessary array allocations.

Apply this diff:

+import { useLayoutEffect, useRef, useState, useMemo } from "react";
+
 export default function PageClient() {
   // ... state declarations ...
   
   // Computed values
-  const existingProducts = typedEntries(paymentsConfig.products)
+  const existingProducts = useMemo(() => typedEntries(paymentsConfig.products)
     .map(([id, product]) => ({
       id,
       displayName: product.displayName,
       catalogId: product.catalogId,
       customerType: product.customerType
-    }));
+    })), [paymentsConfig.products]);

-  const existingItems = typedEntries(paymentsConfig.items).map(([id, item]) => ({
+  const existingItems = useMemo(() => typedEntries(paymentsConfig.items).map(([id, item]) => ({
     id,
     displayName: item.displayName,
     customerType: item.customerType
-  }));
+  })), [paymentsConfig.items]);
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 507d641 and 50235b7.

📒 Files selected for processing (11)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx (4 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (18 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (3 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (11 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (5 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/layout.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Always add new E2E tests when changing the API or SDK interface
For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling
Use ES6 maps instead of records wherever you can

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,css}: Keep hover/click transitions snappy and fast; avoid fade-in delays on hover. Apply transitions after action completion instead, like smooth fade-out when hover ends
Use hover-exit transitions instead of hover-enter transitions; for example, use 'transition-colors hover:transition-none' instead of fade-in on hover

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
apps/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
{.env*,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (AGENTS.md)

Prefix environment variables with STACK_ (or NEXT_PUBLIC_STACK_ if public) so changes are picked up by Turborepo and improves readability

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
apps/dashboard/**/*

📄 CodeRabbit inference engine (AGENTS.md)

When making changes in the dashboard, provide the user with a deep link to the dashboard page changed, usually in the form of http://localhost:<NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/... or using a.localhost, b.localhost, c.localhost for port prefixes 91, 92, 93

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
apps/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx
🧠 Learnings (6)
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/**/*.tsx : Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/dashboard/**/* : When making changes in the dashboard, provide the user with a deep link to the dashboard page changed, usually in the form of http://localhost:<NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/... or using a.localhost, b.localhost, c.localhost for port prefixes 91, 92, 93

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
📚 Learning: 2025-10-11T04:13:19.308Z
Learnt from: N2D4
Repo: stack-auth/stack-auth PR: 943
File: examples/convex/app/action/page.tsx:23-28
Timestamp: 2025-10-11T04:13:19.308Z
Learning: In the stack-auth codebase, use `runAsynchronouslyWithAlert` from `stackframe/stack-shared/dist/utils/promises` for async button click handlers and form submissions instead of manual try/catch blocks. This utility automatically handles errors and shows alerts to users.

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/**/*.{ts,tsx} : NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
🧬 Code graph analysis (6)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx (4)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (3)
  • Product (8-8)
  • Price (9-9)
  • generateUniqueId (155-157)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (1)
  • PageClient (66-87)
packages/stack-shared/src/config/schema.ts (1)
  • CompleteConfig (1074-1074)
packages/stack-shared/src/utils/objects.tsx (1)
  • typedEntries (263-265)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx (4)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (1)
  • PageClient (73-96)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx (1)
  • PageClient (176-1023)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (1)
  • PageClient (598-945)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (1)
  • PageClient (66-87)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx (3)
apps/dashboard/src/components/repeating-input.tsx (1)
  • RepeatingInput (79-278)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (3)
  • PRICE_INTERVAL_UNITS (17-17)
  • DEFAULT_INTERVAL_UNITS (16-16)
  • Price (9-9)
apps/dashboard/src/components/editable-grid.tsx (1)
  • EditableGrid (383-399)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (3)
  • Price (9-9)
  • generateUniqueId (155-157)
  • formatPriceDisplay (111-122)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx (5)
  • EditingPrice (30-40)
  • editingPriceToPrice (283-298)
  • createNewEditingPrice (266-278)
  • priceToEditingPrice (249-261)
  • PriceEditDialog (51-244)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (6)
  • Product (8-8)
  • PricesObject (10-10)
  • intervalLabel (26-34)
  • freeTrialLabel (50-55)
  • getPricesObject (127-137)
  • shortIntervalLabel (39-45)
packages/stack-shared/src/utils/objects.tsx (1)
  • typedEntries (263-265)
packages/stack-shared/src/utils/numbers.tsx (1)
  • prettyPrintWithMagnitudes (9-26)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (1)
  • ListSection (19-104)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx (1)
  • PricingSection (30-301)
🪛 Biome (2.1.2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx

[error] 449-449: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: Cursor Bugbot
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: all-good
  • GitHub Check: setup-tests
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: restart-dev-and-test
  • GitHub Check: docker
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
🔇 Additional comments (11)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (2)

63-98: LGTM: Well-structured nullable handling.

The updated buildPriceUpdate correctly handles three distinct cases for freeTrial: explicit removal (null), explicit value (DayInterval), and computed from selection state (undefined). The JSDoc clarifies the semantics well.


108-122: LGTM: Clean price formatting utility.

The formatPriceDisplay helper provides consistent price formatting across the UI. Pluralization logic is correct.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page.tsx (1)

1-10: LGTM: Standard Next.js page pattern.

The page correctly exports metadata and renders the PageClient component. Based on learnings, here's a deep link to test the new product creation page:

http://localhost:8101/projects/-selector-/payments/products/new

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (1)

410-410: LGTM: Router navigation for product details.

The onClick handler correctly navigates to the product detail page using the router. Based on learnings, here's the deep link pattern:

http://localhost:8101/projects/-selector-/payments/products/{productId}

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx (1)

1-180: LGTM: Well-structured preview component.

The ProductCardPreview component provides a clean card-based preview of products with pricing, badges, and included items. The component correctly uses utility functions for formatting and handles edge cases (no prices, no items) gracefully.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/price-edit-dialog.tsx (1)

1-299: LGTM: Comprehensive price editing dialog.

The PriceEditDialog component and its helper functions provide a solid foundation for price editing. The conversion functions (priceToEditingPrice, createNewEditingPrice, editingPriceToPrice) correctly transform between domain and UI representations.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/pricing-section.tsx (1)

1-301: LGTM: Flexible pricing section component.

The PricingSection component elegantly handles both dialog and form variants, plus a special free-product state. State management is clean with proper separation between editing state (editingPrice) and persisted prices.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (2)

12-64: LGTM: Streamlined welcome screen with proper navigation.

The updated WelcomeScreen correctly uses the projectId prop and Link component for navigation to the new product page. This follows Next.js best practices by avoiding dynamic functions and keeping the page static.

Based on learnings, here's the deep link to the products page:

http://localhost:8101/projects/-selector-/payments/products


66-87: LGTM: Simplified page logic.

The refactored PageClient correctly uses useProjectId and removes the previous multi-view complexity. The conditional rendering (WelcomeScreen vs PageClientListView) is clean and appropriate.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-dialog.tsx (1)

627-633: LGTM: Clean PricingSection integration.

The replacement of inline price management with the PricingSection component simplifies the dialog and centralizes pricing logic. The variant="dialog" prop is used appropriately.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx (1)

126-126: LGTM! Excellent use of hover-exit transitions.

The transition patterns correctly follow the coding guidelines by using "transition-all duration-150 hover:transition-none", which applies transitions on hover-exit rather than hover-enter. This keeps interactions snappy and responsive.

As per coding guidelines: "Use hover-exit transitions instead of hover-enter transitions."

Also applies to: 591-591, 626-626, 708-708

Comment on lines +70 to +154
function CustomerTypeSelection({
onSelectCustomerType,
onCancel,
}: {
onSelectCustomerType: (type: 'user' | 'team' | 'custom') => void,
onCancel: () => void,
}) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-4 px-6 py-4 border-b border-border/40">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Typography type="h3" className="font-semibold">Create Product</Typography>
</div>
<div className="flex-1 flex items-center justify-center p-6">
<div className="space-y-4 max-w-md mx-auto">
<div className="text-center mb-8">
<Typography type="h2" className="text-2xl font-semibold">Who will this product be for?</Typography>
</div>

<div className="grid gap-3">
{CUSTOMER_TYPE_OPTIONS.map((option) => {
const Icon = option.icon;
const colorClasses = {
blue: {
hover: 'hover:border-blue-500/40 hover:shadow-[0_0_12px_rgba(59,130,246,0.1)]',
bg: 'bg-blue-500/10 dark:bg-blue-500/[0.15] group-hover:bg-blue-500/20',
icon: 'text-blue-600 dark:text-blue-400',
},
emerald: {
hover: 'hover:border-emerald-500/40 hover:shadow-[0_0_12px_rgba(16,185,129,0.1)]',
bg: 'bg-emerald-500/10 dark:bg-emerald-500/[0.15] group-hover:bg-emerald-500/20',
icon: 'text-emerald-600 dark:text-emerald-400',
},
amber: {
hover: 'hover:border-amber-500/40 hover:shadow-[0_0_12px_rgba(245,158,11,0.1)]',
bg: 'bg-amber-500/10 dark:bg-amber-500/[0.15] group-hover:bg-amber-500/20',
icon: 'text-amber-600 dark:text-amber-400',
},
}[option.color];

return (
<Card
key={option.value}
className={cn(
"cursor-pointer group",
"rounded-xl border border-border/50 dark:border-foreground/[0.1]",
"bg-foreground/[0.02] hover:bg-foreground/[0.04]",
colorClasses.hover,
"transition-all duration-150 hover:transition-none"
)}
onClick={() => onSelectCustomerType(option.value)}
>
<CardHeader className="p-4">
<div className="flex items-center gap-3">
<div className={cn(
"p-2.5 rounded-xl transition-colors duration-150 group-hover:transition-none",
colorClasses.bg
)}>
<Icon className={cn("h-5 w-5", colorClasses.icon)} />
</div>
<div>
<CardTitle className="text-base font-semibold">{option.label}</CardTitle>
<CardDescription className="text-sm mt-1 text-muted-foreground">
{option.description}
</CardDescription>
</div>
</div>
</CardHeader>
</Card>
);
})}
</div>
</div>
</div>
</div>
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Consider keyboard accessibility for customer type cards.

The customer type selection cards are clickable via onClick but lack keyboard navigation support. Screen reader users and keyboard-only users cannot easily interact with these cards.

Apply this diff to improve accessibility:

               <Card
                 key={option.value}
+                role="button"
+                tabIndex={0}
                 className={cn(
                   "cursor-pointer group",
                   "rounded-xl border border-border/50 dark:border-foreground/[0.1]",
                   "bg-foreground/[0.02] hover:bg-foreground/[0.04]",
                   colorClasses.hover,
                   "transition-all duration-150 hover:transition-none"
                 )}
                 onClick={() => onSelectCustomerType(option.value)}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter' || e.key === ' ') {
+                    e.preventDefault();
+                    onSelectCustomerType(option.value);
+                  }
+                }}
               >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function CustomerTypeSelection({
onSelectCustomerType,
onCancel,
}: {
onSelectCustomerType: (type: 'user' | 'team' | 'custom') => void,
onCancel: () => void,
}) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-4 px-6 py-4 border-b border-border/40">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Typography type="h3" className="font-semibold">Create Product</Typography>
</div>
<div className="flex-1 flex items-center justify-center p-6">
<div className="space-y-4 max-w-md mx-auto">
<div className="text-center mb-8">
<Typography type="h2" className="text-2xl font-semibold">Who will this product be for?</Typography>
</div>
<div className="grid gap-3">
{CUSTOMER_TYPE_OPTIONS.map((option) => {
const Icon = option.icon;
const colorClasses = {
blue: {
hover: 'hover:border-blue-500/40 hover:shadow-[0_0_12px_rgba(59,130,246,0.1)]',
bg: 'bg-blue-500/10 dark:bg-blue-500/[0.15] group-hover:bg-blue-500/20',
icon: 'text-blue-600 dark:text-blue-400',
},
emerald: {
hover: 'hover:border-emerald-500/40 hover:shadow-[0_0_12px_rgba(16,185,129,0.1)]',
bg: 'bg-emerald-500/10 dark:bg-emerald-500/[0.15] group-hover:bg-emerald-500/20',
icon: 'text-emerald-600 dark:text-emerald-400',
},
amber: {
hover: 'hover:border-amber-500/40 hover:shadow-[0_0_12px_rgba(245,158,11,0.1)]',
bg: 'bg-amber-500/10 dark:bg-amber-500/[0.15] group-hover:bg-amber-500/20',
icon: 'text-amber-600 dark:text-amber-400',
},
}[option.color];
return (
<Card
key={option.value}
className={cn(
"cursor-pointer group",
"rounded-xl border border-border/50 dark:border-foreground/[0.1]",
"bg-foreground/[0.02] hover:bg-foreground/[0.04]",
colorClasses.hover,
"transition-all duration-150 hover:transition-none"
)}
onClick={() => onSelectCustomerType(option.value)}
>
<CardHeader className="p-4">
<div className="flex items-center gap-3">
<div className={cn(
"p-2.5 rounded-xl transition-colors duration-150 group-hover:transition-none",
colorClasses.bg
)}>
<Icon className={cn("h-5 w-5", colorClasses.icon)} />
</div>
<div>
<CardTitle className="text-base font-semibold">{option.label}</CardTitle>
<CardDescription className="text-sm mt-1 text-muted-foreground">
{option.description}
</CardDescription>
</div>
</div>
</CardHeader>
</Card>
);
})}
</div>
</div>
</div>
</div>
);
}
function CustomerTypeSelection({
onSelectCustomerType,
onCancel,
}: {
onSelectCustomerType: (type: 'user' | 'team' | 'custom') => void,
onCancel: () => void,
}) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-4 px-6 py-4 border-b border-border/40">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Typography type="h3" className="font-semibold">Create Product</Typography>
</div>
<div className="flex-1 flex items-center justify-center p-6">
<div className="space-y-4 max-w-md mx-auto">
<div className="text-center mb-8">
<Typography type="h2" className="text-2xl font-semibold">Who will this product be for?</Typography>
</div>
<div className="grid gap-3">
{CUSTOMER_TYPE_OPTIONS.map((option) => {
const Icon = option.icon;
const colorClasses = {
blue: {
hover: 'hover:border-blue-500/40 hover:shadow-[0_0_12px_rgba(59,130,246,0.1)]',
bg: 'bg-blue-500/10 dark:bg-blue-500/[0.15] group-hover:bg-blue-500/20',
icon: 'text-blue-600 dark:text-blue-400',
},
emerald: {
hover: 'hover:border-emerald-500/40 hover:shadow-[0_0_12px_rgba(16,185,129,0.1)]',
bg: 'bg-emerald-500/10 dark:bg-emerald-500/[0.15] group-hover:bg-emerald-500/20',
icon: 'text-emerald-600 dark:text-emerald-400',
},
amber: {
hover: 'hover:border-amber-500/40 hover:shadow-[0_0_12px_rgba(245,158,11,0.1)]',
bg: 'bg-amber-500/10 dark:bg-amber-500/[0.15] group-hover:bg-amber-500/20',
icon: 'text-amber-600 dark:text-amber-400',
},
}[option.color];
return (
<Card
key={option.value}
role="button"
tabIndex={0}
className={cn(
"cursor-pointer group",
"rounded-xl border border-border/50 dark:border-foreground/[0.1]",
"bg-foreground/[0.02] hover:bg-foreground/[0.04]",
colorClasses.hover,
"transition-all duration-150 hover:transition-none"
)}
onClick={() => onSelectCustomerType(option.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectCustomerType(option.value);
}
}}
>
<CardHeader className="p-4">
<div className="flex items-center gap-3">
<div className={cn(
"p-2.5 rounded-xl transition-colors duration-150 group-hover:transition-none",
colorClasses.bg
)}>
<Icon className={cn("h-5 w-5", colorClasses.icon)} />
</div>
<div>
<CardTitle className="text-base font-semibold">{option.label}</CardTitle>
<CardDescription className="text-sm mt-1 text-muted-foreground">
{option.description}
</CardDescription>
</div>
</div>
</CardHeader>
</Card>
);
})}
</div>
</div>
</div>
</div>
);
}
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
around lines 70 to 154, the customer type Card elements are only clickable via
onClick and are not keyboard accessible; make each Card focusable and operable
via keyboard by adding tabIndex={0}, role="button", and an onKeyDown handler
that triggers onSelectCustomerType when Enter or Space is pressed; also include
an accessible name (aria-label or ensure option.label is announced) and preserve
the existing onClick behavior so both mouse and keyboard activate the same
action.

Comment on lines +328 to +357
const handleSave = async () => {
const validationErrors = validateForm();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}

setIsSaving(true);
try {
const product: Product = {
displayName,
customerType,
catalogId: catalogId || undefined,
isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false,
stackable,
prices: freeByDefault ? 'include-by-default' : prices,
includedItems,
serverOnly,
freeTrial,
};

await project.updateConfig({ [`payments.products.${productId}`]: product });
toast({ title: "Product created" });
router.push(`/projects/${projectId}/payments/products`);
} catch (error) {
toast({ title: "Failed to create product", variant: "destructive" });
} finally {
setIsSaving(false);
}
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Replace toast with alert for blocking errors.

Line 353 uses toast for error feedback in handleSave, but the coding guidelines specify: "For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user." Additionally, the try-catch pattern swallows detailed error information that could help with debugging.

As per coding guidelines, errors should use alerts or runAsynchronouslyWithAlert.

Apply this diff to use the recommended error handling pattern:

 const handleSave = async () => {
   const validationErrors = validateForm();
   if (Object.keys(validationErrors).length > 0) {
     setErrors(validationErrors);
     return;
   }

   setIsSaving(true);
-  try {
+  runAsynchronouslyWithAlert(async () => {
     const product: Product = {
       displayName,
       customerType,
       catalogId: catalogId || undefined,
       isAddOnTo: isAddOn ? Object.fromEntries(isAddOnTo.map(id => [id, true])) : false,
       stackable,
       prices: freeByDefault ? 'include-by-default' : prices,
       includedItems,
       serverOnly,
       freeTrial,
     };

     await project.updateConfig({ [`payments.products.${productId}`]: product });
     toast({ title: "Product created" });
     router.push(`/projects/${projectId}/payments/products`);
-  } catch (error) {
-    toast({ title: "Failed to create product", variant: "destructive" });
+  }, {
+    finally: () => {
+      setIsSaving(false);
+    }
-  } finally {
-    setIsSaving(false);
-  }
+  });
 };
🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
around lines 328 to 357, replace the non-blocking toast used in the catch with
the project standard blocking alert pattern: wrap the async save in
runAsynchronouslyWithAlert or, if keeping the try/catch, call the alert API (or
runAsynchronouslyWithAlert) with a clear title and include the caught error
message/details; also ensure the caught error is logged (console.error or
process logger) for debugging and preserve the finally block that sets
setIsSaving(false).

Comment on lines +807 to +843
{isAddOn && (
<div className="mt-1.5 mb-0.5 space-y-1 p-2 rounded-lg bg-foreground/[0.02] border border-border/30">
<span className="text-xs text-foreground/50">Add-on to:</span>
<div className="space-y-1 max-h-24 overflow-y-auto">
{existingProducts.filter(o => !o.id.startsWith('addon')).map(product => (
<label key={product.id} className="flex items-center gap-2 cursor-pointer">
<Checkbox
id={`addon-to-${product.id}`}
checked={isAddOnTo.includes(product.id)}
onCheckedChange={(checked) => {
if (checked) {
setIsAddOnTo(prev => [...prev, product.id]);
} else {
setIsAddOnTo(prev => prev.filter(id => id !== product.id));
}
if (errors.isAddOnTo) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors.isAddOnTo;
return newErrors;
});
}
}}
/>
<span className="text-sm">{product.displayName}</span>
</label>
))}
</div>
{errors.isAddOnTo && (
<Typography type="label" className="text-destructive text-xs">
{errors.isAddOnTo}
</Typography>
)}
</div>
)}
</div>
</>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate customer type consistency for add-ons.

The add-on selection logic (line 811) filters products by checking if their ID starts with 'addon', but there's no validation ensuring that the selected add-on products have a compatible customer type with the current product. This could lead to data integrity issues where add-ons and their parent products have mismatched customer types.

Consider adding this validation in the validateForm function:

   if (isAddOn && isAddOnTo.length > 0) {
     const addOnCatalogs = new Set(
       isAddOnTo.map(pid => existingProducts.find(o => o.id === pid)?.catalogId)
     );
     if (addOnCatalogs.size > 1) {
       newErrors.isAddOnTo = "All selected products must be in the same catalog";
     }
+    
+    // Validate customer type compatibility
+    const incompatibleProducts = isAddOnTo.filter(pid => {
+      const product = existingProducts.find(o => o.id === pid);
+      return product && product.customerType !== customerType;
+    });
+    if (incompatibleProducts.length > 0) {
+      newErrors.isAddOnTo = "Add-on products must have the same customer type as the selected products";
+    }
   }

Additionally, filter the product list to only show compatible products:

                         <div className="space-y-1 max-h-24 overflow-y-auto">
-                          {existingProducts.filter(o => !o.id.startsWith('addon')).map(product => (
+                          {existingProducts.filter(o => !o.id.startsWith('addon') && o.customerType === customerType).map(product => (
                             <label key={product.id} className="flex items-center gap-2 cursor-pointer">

Committable suggestion skipped: line range outside the PR's diff.

<div className="mt-1.5 mb-0.5 space-y-1 p-2 rounded-lg bg-foreground/[0.02] border border-border/30">
<span className="text-xs text-foreground/50">Add-on to:</span>
<div className="space-y-1 max-h-24 overflow-y-auto">
{existingProducts.filter(o => !o.id.startsWith('addon')).map(product => (
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Document or replace magic string 'addon' prefix convention.

Line 811 filters products using .filter(o => !o.id.startsWith('addon')), which relies on an undocumented naming convention. This assumption is fragile and could break if product IDs don't follow the expected pattern.

Consider one of these approaches:

  1. Add a field to Product type to explicitly mark add-ons:
// In utils.ts or schema
export function isAddOnProduct(product: Product): boolean {
  return typeof product.isAddOnTo === 'object' && Object.keys(product.isAddOnTo).length > 0;
}
  1. Document the convention with a comment:
// Filter out add-on products (conventionally prefixed with 'addon-')
{existingProducts.filter(o => !o.id.startsWith('addon')).map(product => (

The first approach is more robust and type-safe.

Comment on lines +994 to +1020
<IncludedItemDialog
open={showItemDialog}
onOpenChange={setShowItemDialog}
existingItems={existingItems}
existingIncludedItemIds={Object.keys(includedItems)}
editingItemId={editingItemId}
editingItem={editingItemId ? includedItems[editingItemId] : undefined}
onSave={(itemId, item) => {
if (editingItemId) {
editIncludedItem(itemId, item);
} else {
addIncludedItem(itemId, item);
}
}}
onCreateNewItem={() => setShowNewItemDialog(true)}
/>

<ItemDialog
open={showNewItemDialog}
onOpenChange={setShowNewItemDialog}
onSave={async (item) => {
await project.updateConfig({ [`payments.items.${item.id}`]: { displayName: item.displayName, customerType: item.customerType } });
toast({ title: "Item created" });
}}
existingItemIds={Object.keys(paymentsConfig.items)}
forceCustomerType={customerType}
/>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find IncludedItemDialog component
find . -type f -name "*.tsx" -o -name "*.ts" | xargs grep -l "IncludedItemDialog" | head -20

Repository: stack-auth/stack-auth

Length of output: 377


🏁 Script executed:

#!/bin/bash
# Read the IncludedItemDialog component file
cat -n ./apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/included-item-dialog.tsx

Repository: stack-auth/stack-auth

Length of output: 16380


🏁 Script executed:

#!/bin/bash
# Read the page-client.tsx around lines 994-1020 and context
wc -l ./apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/new/page-client.tsx

Repository: stack-auth/stack-auth

Length of output: 172


🏁 Script executed:

#!/bin/bash
# Read the page-client.tsx to understand the context around lines 994-1020
sed -n '950,1023p' ./apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/new/page-client.tsx

Repository: stack-auth/stack-auth

Length of output: 2579


🏁 Script executed:

#!/bin/bash
# Search for existingItems definition and customerType in the file
sed -n '1,300p' ./apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/new/page-client.tsx | grep -n "existingItems\|customerType" | head -30

Repository: stack-auth/stack-auth

Length of output: 601


🏁 Script executed:

#!/bin/bash
# Read more context around customerType and existingItems construction
sed -n '180,270p' ./apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/new/page-client.tsx

Repository: stack-auth/stack-auth

Length of output: 3336


🏁 Script executed:

#!/bin/bash
# Search for addIncludedItem and editIncludedItem handlers to see if there's validation
grep -n "addIncludedItem\|editIncludedItem" ./apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/new/page-client.tsx | head -20

Repository: stack-auth/stack-auth

Length of output: 306


🏁 Script executed:

#!/bin/bash
# Read the addIncludedItem and editIncludedItem handler implementations
sed -n '359,370p' ./apps/dashboard/src/app/\(main\)/\(protected\)/projects/\[projectId\]/payments/products/new/page-client.tsx

Repository: stack-auth/stack-auth

Length of output: 403


Verify customer type enforcement in included item selection.

The IncludedItemDialog displays all items from existingItems regardless of customer type, while ItemDialog enforces forceCustomerType={customerType} for new items. There is no filtering or validation to prevent selecting items with mismatched customer types. Filter existingItems by the current product's customerType before passing to IncludedItemDialog, or add validation in the component to prevent mismatched selections.

🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/new/page-client.tsx
around lines 994 to 1020, IncludedItemDialog is being fed existingItems without
enforcing the product's customerType so users can pick items with a mismatched
customerType; update the code to supply only items whose customerType matches
the current product (e.g., filter existingItems by item.customerType ===
customerType) before passing to IncludedItemDialog, and/or add a guard in
IncludedItemDialog to validate selected items’ customerType and reject or show
an error for mismatched selections; ensure existingIncludedItemIds and
editingItem lookups are based on the filtered list so indices/ids remain
consistent.

setFreeTrialPopoverOpen(false);
toast({ title: "Free trial removed" });
};

Copy link

Choose a reason for hiding this comment

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

Bug: Free trial editor ignores current value

The free trial popover initializes freeTrialCount/freeTrialUnit to 7 day and never syncs them from product.freeTrial. Opening the editor for an existing free trial and clicking “Save” can overwrite it with the default value even if the user didn’t change anything.

Fix in Cursor Fix in Web

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

Caution

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

⚠️ Outside diff range comments (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx (1)

117-119: Add loading state during async operations.

When onCreate is async, the "Create Catalog" button should be disabled and show a loading indicator to prevent duplicate submissions and provide visual feedback to the user.

Add a loading state:

 export function CreateCatalogDialog({ open, onOpenChange, onCreate }: CreateCatalogDialogProps) {
   const [catalogId, setCatalogId] = useState("");
   const [displayName, setDisplayName] = useState("");
   const [errors, setErrors] = useState<{ id?: string, displayName?: string }>({});
+  const [isCreating, setIsCreating] = useState(false);
   const validateAndCreate = () => {
     const newErrors: { id?: string, displayName?: string } = {};

     // Validate catalog ID
     if (!catalogId.trim()) {
       newErrors.id = "Catalog ID is required";
     } else if (!/^[a-z0-9-]+$/.test(catalogId)) {
       newErrors.id = "Catalog ID must contain only lowercase letters, numbers, and hyphens";
     }

     // Validate display name
     if (!displayName.trim()) {
       newErrors.displayName = "Display name is required";
     }

     if (Object.keys(newErrors).length > 0) {
       setErrors(newErrors);
       return;
     }

+    setIsCreating(true);
     runAsynchronouslyWithAlert(async () => {
       await onCreate({ id: catalogId.trim(), displayName: displayName.trim() });
       
       // Reset form only after successful creation
       setCatalogId("");
       setDisplayName("");
       setErrors({});
       onOpenChange(false);
+    }, {
+      onFinally: () => setIsCreating(false),
     });
   };
-        <DialogFooter>
-          <Button variant="outline" onClick={handleClose}>
+        <DialogFooter>
+          <Button variant="outline" onClick={handleClose} disabled={isCreating}>
             Cancel
           </Button>
-          <Button onClick={validateAndCreate}>
+          <Button onClick={validateAndCreate} disabled={isCreating}>
-            Create Catalog
+            {isCreating ? "Creating..." : "Create Catalog"}
           </Button>
         </DialogFooter>
♻️ Duplicate comments (12)
apps/dashboard/src/components/editable-grid.tsx (2)

137-158: Use runAsynchronouslyWithAlert for user-facing error handling.

The current implementation uses runAsynchronously, which logs errors but doesn't show alerts to users. When onUpdate fails, users won't know why their change wasn't saved. Per coding guidelines and learnings, use runAsynchronouslyWithAlert for form submissions and user-triggered updates.

Apply this diff:

+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";

   ...
   
   <Select
     value={value ? 'true' : 'false'}
-    onValueChange={(v) => runAsynchronously(handleChange(v))}
+    onValueChange={(v) => runAsynchronouslyWithAlert(handleChange(v))}
     disabled={isUpdating}
   >

Based on learnings, runAsynchronouslyWithAlert automatically handles errors and shows alerts to users.


196-219: Use runAsynchronouslyWithAlert for user-facing error handling.

Same issue as EditableBooleanField: errors from onUpdate are logged but not shown to users. When dropdown updates fail, users won't receive feedback.

Apply this diff:

   <Select
     value={value}
-    onValueChange={(v) => runAsynchronously(handleChange(v))}
+    onValueChange={(v) => runAsynchronouslyWithAlert(handleChange(v))}
     disabled={isUpdating}
   >
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (7)

279-281: Sync free trial state when popover opens.

The free trial popover always shows defaults (7 days) instead of the current value. When opening, the local state should be initialized from product.freeTrial.

Consider adding a handler to sync state when the popover opens:

+  // Sync state when popover opens
+  const handleFreeTrialPopoverOpen = (open: boolean) => {
+    if (open && product.freeTrial) {
+      setFreeTrialCount(product.freeTrial[0]);
+      setFreeTrialUnit(product.freeTrial[1]);
+    }
+    setFreeTrialPopoverOpen(open);
+  };

Then update line 459:

-        <Popover open={freeTrialPopoverOpen} onOpenChange={setFreeTrialPopoverOpen}>
+        <Popover open={freeTrialPopoverOpen} onOpenChange={handleFreeTrialPopoverOpen}>

106-106: Add null check for catalog reference.

If the referenced catalog has been deleted but catalogId remains, accessing .displayName will throw a TypeError and crash the page.

Apply this diff to add a safety check:

-  const catalogName = catalogId ? config.payments.catalogs[catalogId].displayName || catalogId : null;
+  const catalogName = catalogId ? config.payments.catalogs[catalogId]?.displayName || catalogId : null;

143-143: Add null checks for referenced products and items.

Multiple locations access config entity properties without verifying the entity exists. If a referenced product, catalog, or item is deleted but references remain (stale data), these will throw TypeErrors:

  • Line 143: config.payments.products[parentId].displayName
  • Line 1046: config.payments.items[itemId].displayName

Apply optional chaining to prevent crashes:

-      displayName: config.payments.products[parentId].displayName || parentId,
+      displayName: config.payments.products[parentId]?.displayName || parentId,

And at line 1046:

-            const displayName = itemConfig.displayName || itemId;
+            const displayName = itemConfig?.displayName || itemId;

361-376: Use alert for blocking validation error.

Per coding guidelines, blocking errors should use alerts instead of toasts as they are less easily missed by users.

Based on learnings, ...

Replace the toast with an alert:

   const handleAddOnSave = async () => {
     if (isAddOn && selectedAddOnProducts.size === 0) {
-      toast({ title: "Please select at least one product", variant: "destructive" });
+      alert("Please select at least one product");
       return;
     }

319-392: Add error handling to async handlers.

Multiple async handlers (handleDisplayNameUpdate, handleCatalogUpdate, handleCreateCatalog, handleStackableUpdate, handleServerOnlyUpdate, handleAddOnSave, handleFreeTrialSave, handleRemoveFreeTrial) lack error handling. If project.updateConfig fails, errors will be unhandled.

Based on learnings, use runAsynchronouslyWithAlert for async handlers:

+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";

Then wrap each handler. Example for handleDisplayNameUpdate:

-  const handleDisplayNameUpdate = async (value: string) => {
+  const handleDisplayNameUpdate = (value: string) => runAsynchronouslyWithAlert(async () => {
     await project.updateConfig({
       [`payments.products.${productId}.displayName`]: value || null,
     });
     toast({ title: "Display name updated" });
-  };
+  });

Apply the same pattern to all other async handlers in this section.


657-675: Missing error handling in price deletion.

The try/finally block clears deletingPriceIds even if deletion fails, but errors are silently swallowed.

Based on learnings, ...

Wrap the handler with runAsynchronouslyWithAlert:

-  const handleDeletePrice = async (priceId: string) => {
+  const handleDeletePrice = (priceId: string) => runAsynchronouslyWithAlert(async () => {
     setDeletingPriceIds(prev => new Set(prev).add(priceId));
     try {
       const currentPrices = prices === 'include-by-default' ? {} : prices;
       const { [priceId]: _, ...remainingPrices } = currentPrices as Record<string, Price>;

       await project.updateConfig({
         [`payments.products.${productId}.prices`]: Object.keys(remainingPrices).length > 0 ? remainingPrices : {},
       });

       toast({ title: "Price deleted" });
     } finally {
       setDeletingPriceIds(prev => {
         const next = new Set(prev);
         next.delete(priceId);
         return next;
       });
     }
-  };
+  });

937-945: Use 'never' sentinel for non-repeating items.

Line 939 sets repeat: 'once' for non-repeating items, but shortIntervalLabel in utils expects 'never' as the sentinel for non-repeating. This inconsistency will cause incorrect interval rendering.

Change the sentinel value to match the expected convention:

-    const repeat: DayInterval | 'once' = editing.repeatSelection === 'once'
-      ? 'once'
+    const repeat: DayInterval | 'never' = editing.repeatSelection === 'once'
+      ? 'never'
       : [editing.repeatCount, editing.repeatUnit || 'month'];
apps/dashboard/src/components/data-table/payment-product-table.tsx (1)

107-107: Hook called inside event handler - violates Rules of Hooks.

project.useConfig() is called inside an async onClick handler at line 107. Hooks must be called unconditionally at the top level of a component.

Move the hook call to the component level:

 function ActionsCell({ product }: { product: PaymentProduct }) {
   const [isEditOpen, setIsEditOpen] = useState(false);
   const [isDeleteOpen, setIsDeleteOpen] = useState(false);
   const stackAdminApp = useAdminApp();
   const project = stackAdminApp.useProject();
+  const config = project.useConfig();

   return (
     <>
       ...
       <ActionDialog
         ...
         okButton={{
           label: "Delete",
           onClick: async () => {
-            const config = project.useConfig();
             const updatedProducts = typedFromEntries(
               typedEntries(config.payments.products)
                 .filter(([productId]) => productId !== product.id)
             );
             await project.updateConfig({ "payments.products": updatedProducts });
             toast({ title: "Product deleted" });
           },
         }}
       />
     </>
   );
 }
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (2)

449-449: Hook called inside event handler - violates Rules of Hooks.

project.useConfig() at line 449 is called inside an async onClick handler. This violates React's Rules of Hooks and will cause runtime errors.

Move the hook call to the component level:

 function ProductsList({
   ...
 }: ProductsListProps) {
   const stackAdminApp = useAdminApp();
   const project = stackAdminApp.useProject();
+  const config = project.useConfig();
   const projectId = useProjectId();
   const router = useRouter();
   ...
         okButton={{
           label: "Delete",
           onClick: async () => {
             if (!productToDelete) return;
-            const config = project.useConfig();
             const updatedProducts = typedFromEntries(
               typedEntries(config.payments.products)
                 .filter(([productId]) => productId !== productToDelete.id)
             );
             await project.updateConfig({ "payments.products": updatedProducts });
             toast({ title: "Product deleted" });
             setProductToDelete(null);
           }
         }}

447-457: Add error handling to async delete handlers.

Both the product deletion handler (lines 447-457) and item deletion handler (lines 584-589) lack error handling. If project.updateConfig fails, the error will be unhandled.

Based on learnings, use runAsynchronouslyWithAlert:

+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";

Then wrap the product deletion handler:

         okButton={{
           label: "Delete",
-          onClick: async () => {
+          onClick: () => runAsynchronouslyWithAlert(async () => {
             if (!productToDelete) return;
             const config = project.useConfig();
             const updatedProducts = typedFromEntries(
               typedEntries(config.payments.products)
                 .filter(([productId]) => productId !== productToDelete.id)
             );
             await project.updateConfig({ "payments.products": updatedProducts });
             toast({ title: "Product deleted" });
             setProductToDelete(null);
-          }
+          })
         }}

Apply the same pattern to the item deletion handler at lines 584-589.

🧹 Nitpick comments (2)
apps/dashboard/src/components/editable-grid.tsx (1)

400-402: Consider requiring a stable key for grid items.

Using index as the key can cause React reconciliation issues if items are reordered, filtered, or removed from the middle of the array. Since EditableGrid is a generic component, consumers might perform these operations, leading to state bugs or incorrect focus behavior.

Consider one of these solutions:

Option 1: Add an optional key field to items

 type BaseItemProps = {
+  key?: string,
   icon: React.ReactNode,
   name: string,
   tooltip?: string,
 };

 ...

 {items.map((item, index) => (
-  <GridItemContent key={index} item={item} />
+  <GridItemContent key={item.key ?? index} item={item} />
 ))}

Option 2: Use a combination of name + type as key

-{items.map((item, index) => (
-  <GridItemContent key={index} item={item} />
+{items.map((item) => (
+  <GridItemContent key={`${item.type}-${item.name}`} item={item} />
 ))}

If items are guaranteed to be stable and append-only in all usage contexts, the current implementation is acceptable.

packages/stack-ui/src/components/ui/dropdown-menu.tsx (1)

145-149: Consider constraining icon size and adding accessibility attributes.

The icon container has h-4 w-4, but the icon content itself isn't constrained. If consumers pass an unsized SVG or component, it could overflow. Additionally, the icon lacks accessibility attributes.

Consider applying size constraints to the icon content and adding accessibility:

 {icon && (
-  <span className="absolute left-2.5 flex h-4 w-4 items-center justify-center">
+  <span className="absolute left-2.5 flex h-4 w-4 items-center justify-center [&>*]:h-4 [&>*]:w-4" aria-hidden="true">
     {icon}
   </span>
 )}

The [&>*] selector ensures direct children are sized correctly, and aria-hidden="true" marks decorative icons appropriately.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 50235b7 and 7076c52.

📒 Files selected for processing (7)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (1 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx (2 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (18 hunks)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx (1 hunks)
  • apps/dashboard/src/components/data-table/payment-product-table.tsx (3 hunks)
  • apps/dashboard/src/components/editable-grid.tsx (1 hunks)
  • packages/stack-ui/src/components/ui/dropdown-menu.tsx (10 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Always add new E2E tests when changing the API or SDK interface
For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling
Use ES6 maps instead of records wherever you can

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • packages/stack-ui/src/components/ui/dropdown-menu.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,css}: Keep hover/click transitions snappy and fast; avoid fade-in delays on hover. Apply transitions after action completion instead, like smooth fade-out when hover ends
Use hover-exit transitions instead of hover-enter transitions; for example, use 'transition-colors hover:transition-none' instead of fade-in on hover

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • packages/stack-ui/src/components/ui/dropdown-menu.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
apps/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
{.env*,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (AGENTS.md)

Prefix environment variables with STACK_ (or NEXT_PUBLIC_STACK_ if public) so changes are picked up by Turborepo and improves readability

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • packages/stack-ui/src/components/ui/dropdown-menu.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
apps/dashboard/**/*

📄 CodeRabbit inference engine (AGENTS.md)

When making changes in the dashboard, provide the user with a deep link to the dashboard page changed, usually in the form of http://localhost:<NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/... or using a.localhost, b.localhost, c.localhost for port prefixes 91, 92, 93

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
apps/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx
  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/dashboard/src/components/data-table/payment-product-table.tsx
🧠 Learnings (6)
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling

Applied to files:

  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
📚 Learning: 2025-10-11T04:13:19.308Z
Learnt from: N2D4
Repo: stack-auth/stack-auth PR: 943
File: examples/convex/app/action/page.tsx:23-28
Timestamp: 2025-10-11T04:13:19.308Z
Learning: In the stack-auth codebase, use `runAsynchronouslyWithAlert` from `stackframe/stack-shared/dist/utils/promises` for async button click handlers and form submissions instead of manual try/catch blocks. This utility automatically handles errors and shows alerts to users.

Applied to files:

  • apps/dashboard/src/components/editable-grid.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/dashboard/**/* : When making changes in the dashboard, provide the user with a deep link to the dashboard page changed, usually in the form of http://localhost:<NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/... or using a.localhost, b.localhost, c.localhost for port prefixes 91, 92, 93

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/**/*.{ts,tsx} : NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/**/*.tsx : Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Applied to files:

  • apps/dashboard/src/components/data-table/payment-product-table.tsx
🧬 Code graph analysis (5)
apps/dashboard/src/components/editable-grid.tsx (3)
packages/stack-ui/src/components/ui/select.tsx (5)
  • Select (160-160)
  • SelectTrigger (160-160)
  • SelectValue (160-160)
  • SelectContent (160-160)
  • SelectItem (160-160)
packages/stack-shared/src/utils/promises.tsx (1)
  • runAsynchronously (343-366)
apps/dashboard/src/components/editable-input.tsx (1)
  • EditableInput (20-145)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx (1)
packages/stack-shared/src/utils/promises.tsx (1)
  • runAsynchronously (343-366)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx (6)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx (1)
  • useProjectId (46-53)
apps/dashboard/src/components/router.tsx (1)
  • useRouter (15-33)
packages/stack-shared/src/utils/objects.tsx (2)
  • typedFromEntries (281-283)
  • typedEntries (263-265)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/list-section.tsx (1)
  • ListSection (19-104)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client.tsx (1)
  • PageClient (66-87)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/customers/page-client.tsx (1)
  • PageClient (51-188)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (3)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/utils.ts (6)
  • Product (8-8)
  • DEFAULT_INTERVAL_UNITS (16-16)
  • Price (9-9)
  • generateUniqueId (155-157)
  • intervalLabel (26-34)
  • shortIntervalLabel (39-45)
packages/stack-shared/src/utils/dates.tsx (2)
  • DayInterval (146-146)
  • fromNow (28-30)
apps/e2e/tests/js/convex.test.ts (1)
  • open (31-33)
apps/dashboard/src/components/data-table/payment-product-table.tsx (1)
packages/stack-shared/src/utils/objects.tsx (2)
  • typedFromEntries (281-283)
  • typedEntries (263-265)
🪛 Biome (2.1.2)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx

[error] 449-449: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

apps/dashboard/src/components/data-table/payment-product-table.tsx

[error] 107-107: This hook is being called from a nested function, but all hooks must be called unconditionally from the top-level component.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: Cursor Bugbot
  • GitHub Check: build (22.x)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: docker
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: build (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: all-good
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: setup-tests
🔇 Additional comments (5)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/product-card-preview.tsx (1)

1-180: LGTM! Well-structured product card preview component.

The component is well-implemented with proper type safety, clean separation of concerns (PriceDisplay subcomponent), and graceful handling of edge cases. The use of shared utilities for formatting and the typedEntries helper ensures consistency across the payments UI.

packages/stack-ui/src/components/ui/dropdown-menu.tsx (4)

85-87: LGTM! Consistent styling refinements.

The updated styling (rounded-xl, border-border/50, p-1.5) creates a more polished appearance with softer borders and refined spacing, consistently applied across both SubContent and Content components.

Also applies to: 103-103


164-164: LGTM! Consistent spacing for indicators.

The padding updates (py-2 pl-9 pr-3) and indicator positioning (left-2.5) align with the new icon implementation in DropdownMenuItem, creating visual consistency across checkbox and radio items.

Also applies to: 170-170, 188-188, 193-193


136-140: LGTM! Proper async error handling.

The use of runAsynchronouslyWithAlert correctly handles async operations and errors, following the coding guideline to avoid void promises and try-catch-all patterns.


65-66: Padding changes improve accessibility spacing.

The updates to px-3 py-2 base padding and pl-9 inset padding are minor refinements that align with accessibility best practices. These small adjustments (1-4px differences) enhance spacing for better usability without impacting existing layouts.

@@ -1,12 +1,13 @@
"use client";

import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Dialog closes before async onCreate completes.

The dialog is closed and form reset immediately after starting the async operation (line 38), not after it completes. If onCreate returns a Promise, the dialog will close before knowing whether the operation succeeded or failed, leaving the user without feedback.

Additionally, based on learnings, runAsynchronouslyWithAlert should be used for form submissions instead of runAsynchronously.

Apply this diff to wait for onCreate to complete before closing:

-import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
-  const validateAndCreate = () => {
+  const validateAndCreate = () => {
     const newErrors: { id?: string, displayName?: string } = {};

     // Validate catalog ID
     if (!catalogId.trim()) {
       newErrors.id = "Catalog ID is required";
     } else if (!/^[a-z0-9-]+$/.test(catalogId)) {
       newErrors.id = "Catalog ID must contain only lowercase letters, numbers, and hyphens";
     }

     // Validate display name
     if (!displayName.trim()) {
       newErrors.displayName = "Display name is required";
     }

     if (Object.keys(newErrors).length > 0) {
       setErrors(newErrors);
       return;
     }

-    runAsynchronously(onCreate({ id: catalogId.trim(), displayName: displayName.trim() }));
-
-    // Reset form
-    setCatalogId("");
-    setDisplayName("");
-    setErrors({});
-    onOpenChange(false);
+    runAsynchronouslyWithAlert(async () => {
+      await onCreate({ id: catalogId.trim(), displayName: displayName.trim() });
+      
+      // Reset form only after successful creation
+      setCatalogId("");
+      setDisplayName("");
+      setErrors({});
+      onOpenChange(false);
+    });
   };

Based on learnings, runAsynchronouslyWithAlert handles errors by showing alerts to users, which is appropriate for form submissions.

Also applies to: 38-44

🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx
around lines 38 to 44, the dialog is being closed and the form reset immediately
after kicking off the async onCreate call; change this so the component awaits
the async completion and uses runAsynchronouslyWithAlert (instead of
runAsynchronously) to run the submission, awaiting its result before calling
close and reset; specifically, import runAsynchronouslyWithAlert, call await
runAsynchronouslyWithAlert(() => onCreate(values)) (or similar) and only close
the dialog and reset the form after that await completes so errors are handled
and the dialog remains open until the operation finishes.

Copy link

@vercel vercel bot left a comment

Choose a reason for hiding this comment

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

Additional Suggestion:

Deleting an item by setting it to null (line 586) causes a runtime crash when sorting items, because the code tries to access properties on the null value.

View Details
📝 Patch Details
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
index 90e18db2..b35a50d4 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/page-client-list-view.tsx
@@ -496,15 +496,17 @@ function ItemsList({
   // Sort items by customer type, then by ID
   const sortedItems = useMemo(() => {
     const customerTypePriority = { user: 1, team: 2, custom: 3 };
-    return Object.entries(items).sort(([aId, aItem]: [string, any], [bId, bItem]: [string, any]) => {
-      const priorityA = customerTypePriority[aItem.customerType as keyof typeof customerTypePriority] || 4;
-      const priorityB = customerTypePriority[bItem.customerType as keyof typeof customerTypePriority] || 4;
-      if (priorityA !== priorityB) {
-        return priorityA - priorityB;
-      }
-      // If same customer type, sort by ID
-      return stringCompare(aId, bId);
-    });
+    return Object.entries(items)
+      .filter(([_, item]) => item !== null) // Skip deleted/null items
+      .sort(([aId, aItem]: [string, any], [bId, bItem]: [string, any]) => {
+        const priorityA = customerTypePriority[aItem.customerType as keyof typeof customerTypePriority] || 4;
+        const priorityB = customerTypePriority[bItem.customerType as keyof typeof customerTypePriority] || 4;
+        if (priorityA !== priorityB) {
+          return priorityA - priorityB;
+        }
+        // If same customer type, sort by ID
+        return stringCompare(aId, bId);
+      });
   }, [items]);
 
   // Filter items based on search query

Analysis

Missing null check for items during sorting causes runtime crash on deletion

What fails: When an item is deleted via updateConfig({ [payments.items.{id}]: null }), the sorting logic in ItemsList component attempts to access .customerType on potentially null item values, causing a "Cannot read property 'customerType' of null" error.

How to reproduce:

  1. In the dashboard at /projects/[projectId]/payments
  2. In the Items section, click delete on any item
  3. Confirm deletion - the config updates with payments.items.{id}: null
  4. During the component re-render, the sortedItems useMemo (line 499) executes Object.entries(items).sort(...)
  5. This crashes when it tries to access aItem.customerType on a null value

Result: TypeError: "Cannot read property 'customerType' of null"

Expected: Items should be filtered out before sorting to prevent accessing properties on null values

Fix applied: Added .filter(([_, item]) => item !== null) before the sort operation to skip deleted/null items, matching the defensive pattern already used for products at line 639 (if (!product) return;). While the config normalization should theoretically delete null keys during updateConfig, defensive client-side filtering prevents potential crashes from race conditions or sync issues.

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: 0

♻️ Duplicate comments (10)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (10)

107-107: Accessing catalog without null check will crash if catalog is deleted.

If catalogId references a deleted catalog, config.payments.catalogs[catalogId] returns undefined, and accessing .displayName throws a TypeError. Use optional chaining or explicit null check.

-  const catalogName = catalogId ? config.payments.catalogs[catalogId].displayName || catalogId : null;
+  const catalogName = catalogId ? config.payments.catalogs[catalogId]?.displayName || catalogId : null;

152-158: Parent product access lacks null check.

If a parent product ID in isAddOnTo references a deleted product, accessing config.payments.products[parentId].displayName will throw. Add optional chaining or filter out missing products.

   const addOnParents = useMemo(() => {
     if (product.isAddOnTo === false || typeof product.isAddOnTo !== 'object') return [];
-    return Object.keys(product.isAddOnTo).map((parentId: string) => ({
+    return Object.keys(product.isAddOnTo)
+      .filter(parentId => config.payments.products[parentId])
+      .map((parentId: string) => ({
       id: parentId,
       displayName: config.payments.products[parentId].displayName || parentId,
     }));
   }, [product.isAddOnTo, config.payments.products]);

292-294: Free trial state doesn't sync from product when popover opens.

Lines 292-294 initialize freeTrialCount and freeTrialUnit to hardcoded defaults (7 days). When the popover opens to edit an existing free trial, these values aren't updated from product.freeTrial, so the user sees stale defaults instead of current values.

Sync state when popover opens:

+  // Sync free trial state when popover opens
+  const handleFreeTrialPopoverChange = (open: boolean) => {
+    if (open && freeTrialInfo) {
+      setFreeTrialCount(freeTrialInfo[0]);
+      setFreeTrialUnit(freeTrialInfo[1]);
+    }
+    setFreeTrialPopoverOpen(open);
+  };

Then use onOpenChange={handleFreeTrialPopoverChange} on the Popover at line 472.


332-405: Async handlers lack error handling.

All handlers (handleDisplayNameUpdate, handleCatalogUpdate, handleCreateCatalog, handleStackableUpdate, handleServerOnlyUpdate, handleAddOnSave, handleFreeTrialSave, handleRemoveFreeTrial) call await project.updateConfig(...) without error handling. Per coding guidelines and learnings, wrap these with runAsynchronouslyWithAlert from stackframe/stack-shared/dist/utils/promises to automatically show alerts on failure.

Based on learnings, use runAsynchronouslyWithAlert for async button click handlers and form submissions.


374-378: Use alert instead of toast for blocking validation error.

Validation errors that block user actions should use alerts (not toasts), as alerts are less easily missed. This blocking validation prevents saving invalid add-on configuration.

Based on learnings, for blocking alerts and errors, never use toast; use alerts instead.

   if (isAddOn && selectedAddOnProducts.size === 0) {
-    toast({ title: "Please select at least one product", variant: "destructive" });
+    alert("Please select at least one product");
     return;
   }

670-688: Error handling silently swallows failures in handleDeletePrice.

The try/finally block clears deletingPriceIds even if the delete fails, but doesn't catch or surface errors. Failures are invisible to the user.

Based on learnings, use runAsynchronouslyWithAlert for async handlers instead of manual try/catch blocks.


844-854: Immediate price deletion without confirmation.

Clicking the delete button immediately removes the price without confirmation. Accidental clicks can cause unintended data loss.

Consider adding a confirmation dialog or browser confirm() before calling handleDeletePrice.


950-953: Item repeat saves 'once' but other code expects 'never'.

Line 951 saves 'once' for non-repeating items, but other payments code (e.g., shortIntervalLabel) uses 'never' as the sentinel for non-repeating. This inconsistency can cause incorrect rendering or config state.

-  const repeat: DayInterval | 'once' = editing.repeatSelection === 'once'
-    ? 'once'
+  const repeat: DayInterval | 'never' = editing.repeatSelection === 'once'
+    ? 'never'
     : [editing.repeatCount, editing.repeatUnit || 'month'];

1059-1060: Item access lacks null check.

If itemId references a deleted item, config.payments.items[itemId] returns undefined, and accessing .displayName will throw.

   const itemConfig = config.payments.items[itemId];
+  if (!itemConfig) return null; // or handle gracefully
   const displayName = itemConfig.displayName || itemId;

1334-1334: Hard limit of 100 transactions may hide customers.

If a product has more than 100 purchase transactions, some customers won't appear in the list. Implement pagination or at minimum display a warning when the limit is reached.

Consider detecting when transactions.length === 100 and showing a banner like "Showing first 100 customers. [View All]" or implement cursor-based pagination.

🧹 Nitpick comments (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (1)

704-704: Redundant condition check in isFreeNotIncluded.

The condition priceEntries.length === 1 is checked twice unnecessarily. Simplify using parentheses for clarity.

-  const isFreeNotIncluded = priceEntries.length === 1 && priceEntries[0][1].USD === '0' || priceEntries.length === 1 && priceEntries[0][1].USD === '0.00';
+  const isFreeNotIncluded = priceEntries.length === 1 && (priceEntries[0][1].USD === '0' || priceEntries[0][1].USD === '0.00');
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7076c52 and b974cf9.

📒 Files selected for processing (1)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (1 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Always add new E2E tests when changing the API or SDK interface
For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling
Use ES6 maps instead of records wherever you can

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,css}: Keep hover/click transitions snappy and fast; avoid fade-in delays on hover. Apply transitions after action completion instead, like smooth fade-out when hover ends
Use hover-exit transitions instead of hover-enter transitions; for example, use 'transition-colors hover:transition-none' instead of fade-in on hover

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
apps/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
{.env*,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (AGENTS.md)

Prefix environment variables with STACK_ (or NEXT_PUBLIC_STACK_ if public) so changes are picked up by Turborepo and improves readability

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
apps/dashboard/**/*

📄 CodeRabbit inference engine (AGENTS.md)

When making changes in the dashboard, provide the user with a deep link to the dashboard page changed, usually in the form of http://localhost:<NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/... or using a.localhost, b.localhost, c.localhost for port prefixes 91, 92, 93

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
apps/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
🧠 Learnings (4)
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/dashboard/**/* : When making changes in the dashboard, provide the user with a deep link to the dashboard page changed, usually in the form of http://localhost:<NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/... or using a.localhost, b.localhost, c.localhost for port prefixes 91, 92, 93

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
📚 Learning: 2025-10-11T04:13:19.308Z
Learnt from: N2D4
Repo: stack-auth/stack-auth PR: 943
File: examples/convex/app/action/page.tsx:23-28
Timestamp: 2025-10-11T04:13:19.308Z
Learning: In the stack-auth codebase, use `runAsynchronouslyWithAlert` from `stackframe/stack-shared/dist/utils/promises` for async button click handlers and form submissions instead of manual try/catch blocks. This utility automatically handles errors and shows alerts to users.

Applied to files:

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: Cursor Bugbot
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: all-good
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: docker
  • GitHub Check: setup-tests
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: restart-dev-and-test-with-custom-base-port
🔇 Additional comments (1)
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx (1)

1-3: Deep link for testing this product detail page.

Based on learnings, when making changes in the dashboard, provide a deep link. To test this product detail page locally, navigate to:

http://localhost:8101/projects/-selector-/payments/products/[productId]

Replace [productId] with an actual product ID from your project configuration.

Note: Per PR summary, the new local ports are dashboard 8101, backend 8102.

setEditingItem(null);
setIsAddingItem(false);
setSelectedItemId('');
};
Copy link

Choose a reason for hiding this comment

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

Bug: Included items saved with invalid repeat value

ProductItemsSection writes repeat: 'once' into payments.products.*.includedItems, but the config schema uses 'never' (or a DayInterval) for non-repeating items. After saving, code paths like shortIntervalLabel(item.repeat) can break because they don’t handle 'once'.

Additional Locations (1)

Fix in Cursor Fix in Web

repeatSelection: isOnce ? 'once' : (repeatInterval?.[0] === 1 ? repeatInterval[1] : 'custom') as EditingItem['repeatSelection'],
repeatCount: repeatInterval?.[0] || 1,
repeatUnit: repeatInterval?.[1] as DayInterval[1] | undefined,
});
Copy link

Choose a reason for hiding this comment

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

Bug: Invalid included item repeat value saved

ProductItemsSection writes includedItems[*].repeat as 'once', but the config schema only allows a DayInterval or 'never'. This makes “once” selections produce an invalid config update (likely rejected) and can also cause later code paths to assume a shape that the persisted config can’t represent.

Fix in Cursor Fix in Web

setEditingItem(null);
setIsAddingItem(false);
setSelectedItemId('');
};
Copy link

Choose a reason for hiding this comment

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

Bug: Invalid repeat value stored for items

ProductItemsSection saves includedItems[*].repeat as 'once' when the user selects “Once”, but the config schema for includedItems.repeat only supports a DayInterval or 'never'. This can create invalid config data and can also break rendering logic like shortIntervalLabel(item.repeat) that assumes the 'never' sentinel.

Fix in Cursor Fix in Web

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Bug: Create catalog closes even if creation fails

CreateCatalogDialog now runs onCreate via runAsynchronously(...) and immediately resets state and closes the dialog. If onCreate is async and fails (e.g., updateConfig rejects), the UI still closes and clears inputs, leaving the user with no indication that the catalog wasn’t created.

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx#L6-L45

https://github.com/stack-auth/stack-auth/blob/db396c8d072efa282428a2f5ed3776d081817f0c/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/create-catalog-dialog.tsx#L6-L45

Fix in Cursor Fix in Web


@N2D4 N2D4 merged commit c8fe42d into dev Dec 18, 2025
11 of 25 checks passed
@N2D4 N2D4 deleted the Payments-Redesign branch December 18, 2025 00:34
setEditingItem(null);
setIsAddingItem(false);
setSelectedItemId('');
};
Copy link

Choose a reason for hiding this comment

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

Bug: Invalid included item repeat value

ProductItemsSection persists included-item repeat as 'once', but the payments config schema (and shortIntervalLabel) expects repeat to be a DayInterval tuple or 'never'. This can make project.updateConfig write invalid config and can break rendering when shortIntervalLabel(item.repeat) receives 'once'.

Additional Locations (1)

Fix in Cursor Fix in Web

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