Skip to content

Conversation

@amoscicki
Copy link
Contributor

@amoscicki amoscicki commented Dec 28, 2025

Summary

  • Complete affiliate program enabling users to earn configurable commission (default 30%) by referring new customers
  • Stripe Connect OAuth integration for automatic affiliate payouts with Express accounts
  • GDPR-compliant discount system where affiliate codes provide 10% off while maintaining tracking via Stripe metadata
  • Infrastructure improvements including data table components, logging system, and test fixes

Key Flows

Stripe Connect OAuth Flow

sequenceDiagram
    participant User as Affiliate
    participant App as Application
    participant Stripe as Stripe

    User->>App: Click "Connect with Stripe"
    App->>App: Generate state token, store in cookie
    App->>Stripe: Create Express account
    Stripe-->>App: Return account ID
    App->>Stripe: Create account link
    Stripe-->>App: Return onboarding URL
    App->>User: Redirect to Stripe onboarding
    User->>Stripe: Complete onboarding
    Stripe->>App: Redirect to /api/connect/stripe/callback
    App->>App: Verify state token
    App->>Stripe: Fetch account status
    Stripe-->>App: Return account details
    App->>App: Update affiliate record
    App->>User: Redirect to dashboard
Loading

Affiliate Referral & Discount Flow

sequenceDiagram
    participant Visitor as Visitor
    participant App as Application
    participant Memory as DiscountStore
    participant Stripe as Stripe
    participant DB as Database

    Visitor->>App: Visit /purchase?ref=ABC12345
    App->>Memory: Store affiliate code (GDPR-safe)
    Visitor->>App: Click "Get Instant Access"
    App->>App: Show discount dialog with pre-filled code
    Visitor->>App: Click "Apply 10% Discount"
    App->>DB: Validate affiliate code
    DB-->>App: Code valid & active
    App->>Stripe: Create checkout session with metadata
    Note over Stripe: affiliateCode + discountCoupon
    Visitor->>Stripe: Complete payment
    Stripe->>App: Webhook: checkout.session.completed
    App->>DB: Create referral record
    App->>DB: Calculate & store commission
Loading

Key Changes

Affiliate Program Core

  • User registration flow with Terms of Service modal
  • Unique 8-character affiliate code generation with /purchase?ref={code} tracking links
  • Memory-only code storage for GDPR compliance (no cookies/localStorage)
  • Commission calculation stored in cents to avoid floating-point issues
  • Self-referral prevention and fraud protection mechanisms

Stripe Connect Integration

  • OAuth flow with Express accounts for automatic payouts
  • Account status lifecycle: not_startedonboardingpendingactive
  • Webhook handling for account.updated events to sync status
  • API routes: /api/connect/stripe, /api/connect/stripe/callback, /api/connect/stripe/refresh

Affiliate Dashboard

  • Real-time statistics (earnings, unpaid balance, referrals, paid out)
  • Referral history with sortable columns
  • Payout history with transaction details
  • Monthly earnings chart visualization

Admin Management

  • View all affiliates with statistics and Stripe account status
  • Activate/deactivate affiliate accounts
  • Configurable commission rate settings

Infrastructure

  • Extensible server function logger with scopes
  • Reusable data table components with filtering and pagination
  • NumberInputWithControls component for admin settings
  • Timing-safe comparison for Stripe refresh route
  • URL sanitizer utility

Database

  • New migrations for affiliate tables with enums and CHECK constraints
  • stripeAccountType column for tracking account types
  • Proper indexing and referential integrity

Test Plan

  • Verify affiliate registration flow with Terms of Service
  • Test affiliate link tracking and discount application
  • Complete Stripe Connect OAuth flow and verify account status sync
  • Verify admin dashboard functionality
  • Run existing Playwright tests: npm run test

Summary by CodeRabbit

  • New Features

    • Stripe Connect payouts for affiliates, plus payment-method choice (Payment Link or Stripe)
    • Admin UI: configurable commission rates, minimum payouts, pricing editor, and feature-flags page
    • Affiliate onboarding flow and enhanced affiliate dashboard with discount-split controls and payout details
    • Improved purchase/checkout to support affiliate discounts and commission metadata
    • Enhanced data-table UX: filtering, sorting, column visibility, pagination, and toolbar
  • Documentation

    • Expanded affiliates and Stripe Connect integration docs
  • Tests

    • Test assertions updated to use centralized progress constants

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

@coderabbitai
Copy link

coderabbitai bot commented Dec 28, 2025

Warning

Rate limit exceeded

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

⌛ How to resolve this issue?

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

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

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

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

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between e9b5650 and bb7821f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (81)
  • .env.sample
  • CLAUDE.md
  • docs/features/affiliates/readme.md
  • drizzle/0050_gorgeous_kulan_gath.sql
  • drizzle/0051_chilly_the_watchers.sql
  • drizzle/0052_funny_hardball.sql
  • drizzle/meta/0050_snapshot.json
  • drizzle/meta/0051_snapshot.json
  • drizzle/meta/0052_snapshot.json
  • drizzle/meta/_journal.json
  • package.json
  • src/components/blocks/number-input-with-controls.tsx
  • src/components/data-table/data-table-column-header.tsx
  • src/components/data-table/data-table-date-filter.tsx
  • src/components/data-table/data-table-faceted-filter.tsx
  • src/components/data-table/data-table-pagination.tsx
  • src/components/data-table/data-table-skeleton.tsx
  • src/components/data-table/data-table-slider-filter.tsx
  • src/components/data-table/data-table-toolbar.tsx
  • src/components/data-table/data-table-view-options.tsx
  • src/components/data-table/data-table.tsx
  • src/components/discount-dialog.tsx
  • src/components/emails/affiliate-payout-failed-email.tsx
  • src/components/emails/affiliate-payout-success-email.tsx
  • src/components/feature-flag.tsx
  • src/components/ui/button-group.tsx
  • src/components/ui/button.tsx
  • src/components/ui/calendar.tsx
  • src/components/ui/collapsible.tsx
  • src/components/ui/radio-group.tsx
  • src/components/ui/slider.tsx
  • src/config.ts
  • src/config/data-table.ts
  • src/config/feature-flags.ts
  • src/data-access/affiliates.ts
  • src/data-access/app-settings.ts
  • src/db/schema.ts
  • src/features/affiliates/README.md
  • src/fn/affiliates.ts
  • src/fn/app-settings.ts
  • src/hooks/use-callback-ref.ts
  • src/hooks/use-data-table.ts
  • src/hooks/use-debounced-callback.ts
  • src/lib/auth.ts
  • src/lib/data-table.ts
  • src/lib/format.ts
  • src/lib/parsers.ts
  • src/lib/server-logger.ts
  • src/lib/stripe.ts
  • src/routeTree.gen.ts
  • src/routes/-components/header.tsx
  • src/routes/__root.tsx
  • src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx
  • src/routes/admin/-affiliates-components/affiliates-columns.tsx
  • src/routes/admin/-components/admin-nav.tsx
  • src/routes/admin/affiliates.tsx
  • src/routes/admin/feature-flags.tsx
  • src/routes/admin/pricing.tsx
  • src/routes/admin/route.tsx
  • src/routes/admin/settings.tsx
  • src/routes/affiliate-dashboard.tsx
  • src/routes/affiliate-onboarding.tsx
  • src/routes/affiliates.tsx
  • src/routes/api/connect/stripe/callback/index.ts
  • src/routes/api/connect/stripe/index.ts
  • src/routes/api/connect/stripe/oauth/callback/index.ts
  • src/routes/api/connect/stripe/oauth/index.ts
  • src/routes/api/connect/stripe/refresh/index.ts
  • src/routes/api/login/google/callback/index.ts
  • src/routes/api/stripe/webhook.ts
  • src/routes/purchase.tsx
  • src/types/data-table.ts
  • src/use-cases/affiliates.ts
  • src/use-cases/app-settings.ts
  • src/utils/crypto.ts
  • src/utils/email.ts
  • src/utils/env.ts
  • src/utils/logger.ts
  • src/utils/stripe-status.ts
  • src/utils/url-sanitizer.ts
  • vite.config.ts
📝 Walkthrough

Walkthrough

Adds a full affiliate payments feature: Stripe Connect OAuth/connect flows, webhook processing, automatic payouts and emails, configurable affiliate/pricing settings, new DB enums/columns and indexes, many data-access/use-case/server endpoints, extensive data-table UI components, crypto utilities for CSRF/signing, and standardized { success, data } server responses.

Changes

Cohort / File(s) Summary
Env & Config
\.env.sample, src/utils/env.ts, src/config.ts, src/config/feature-flags.ts, src/config/data-table.ts
New env keys (STRIPE_CLIENT_ID, TOKEN_SIGNING_SECRET); removed dev bypass vars; added affiliate/pricing config keys and feature-flag grouping; data-table configuration added.
DB Migrations & Schema
drizzle/0050_*.sql, drizzle/0051_*.sql, drizzle/0052_*.sql, drizzle/meta/_journal.json, src/db/schema.ts
New enums (affiliate_payout_status, stripe_account_status, affiliate_payment_method), added Stripe/payout fields to affiliates/payouts/referrals, check constraints, new indexes, and altered moduleId type.
Server APIs & Use-Cases
src/fn/affiliates.ts, src/fn/app-settings.ts, src/use-cases/affiliates.ts, src/use-cases/app-settings.ts, src/data-access/*.ts
Many new/updated handlers and use-cases: paymentMethod support, update/validate endpoints, admin payout processing, automatic payouts, Stripe account sync/disconnect, pricing/affiliate settings getters/setters, expanded transactional data-access functions. Responses standardized to { success, data }.
Stripe Connect & Webhooks
src/routes/api/connect/stripe/*.ts, src/routes/api/connect/stripe/oauth/*.ts, src/routes/api/connect/stripe/callback/index.ts, src/routes/api/stripe/webhook.ts, src/lib/stripe.ts, src/utils/stripe-status.ts
New routes for connect/oauth/refresh/callback, CSRF state handling, rate limiting, account creation and linking, webhook signature validation and robust event handling (checkout, account.updated, transfer.*), and removed explicit Stripe API version pin.
Affiliate Pages & Routes
src/routes/affiliate-*.tsx, src/routes/admin/affiliates.tsx, src/routes/admin/pricing.tsx, src/routes/admin/feature-flags.tsx, src/routes/affiliate-onboarding.tsx, src/routeTree.gen.ts
New onboarding wizard, admin pricing and feature-flags pages, redesigned admin affiliates UI with data-table and auto-payout controls; route tree updated.
Data-table System & Hooks
src/components/data-table/*.tsx, src/hooks/use-data-table.ts, src/hooks/use-callback-ref.ts, src/hooks/use-debounced-callback.ts, src/lib/parsers.ts, src/lib/data-table.ts, src/types/data-table.ts
New TanStack-based DataTable and many auxiliary components (filters, toolbar, view options, pagination), URL-synced state parsers, utilities and hooks for robust table behavior.
UI Primitives & Components
src/components/ui/*.tsx, src/components/blocks/number-input-with-controls.tsx, src/components/data-table/*, src/components/dev-menu/dev-user-card.tsx, src/components/discount-dialog.tsx
New UI primitives (ButtonGroup, Collapsible, RadioGroup, Slider, Calendar, updated Button), multiple client components, minor destructuring fixes to match new response shapes.
Emails & Email Utils
src/components/emails/affiliate-payout-*.tsx, src/utils/email.ts
New payout success/failure email templates and switching SES to SendRawEmail with MIME assembly and List-Unsubscribe support.
Security & Crypto Utilities
src/utils/crypto.ts, src/utils/url-sanitizer.ts, src/utils/logger.ts
New timing-safe compare, CSRF state generator, signed unsubscribe tokens, URL sanitizers, and a structured JSON logger.
Profiles / Images
src/data-access/profiles.ts, src/routes/profile/*, src/routes/profile/edit.ts, src/fn/profiles.ts
Generate presigned image URLs on read; upload flow now returns only presignedUrl + imageKey (imageUrl removed).
Server Logging & Middleware
src/lib/server-logger.ts, src/lib/auth.ts
New scoped server log middleware and replaced local logMiddleware import usage.
Build & Tests
package.json, vite.config.ts, tests/*
Added dependencies (@tanstack/react-table, nuqs, react-day-picker, Radix libs, tsx), pnpm onlyBuiltDependencies, ignored routeTree.gen.ts in watcher; tests use centralized PROGRESS constants.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Browser as User Browser
    participant Server as App Server
    participant DB as Database
    participant Stripe as Stripe API
    participant Email as Email Service

    Browser->>Server: GET /api/connect/stripe (initiate)
    Server->>DB: fetch affiliate, check rate limit
    alt no stripe account
        Server->>Stripe: create Account (Express)
        Stripe-->>Server: account_id
        Server->>DB: persist stripeConnectAccountId, status=onboarding
    end
    Server->>Stripe: create account link (return_url with state)
    Stripe-->>Server: onboarding_url
    Server->>Browser: 302 redirect to onboarding_url

    Note over Browser,Stripe: User completes onboarding on Stripe

    Stripe->>Server: redirects to /api/connect/stripe/oauth/callback?code&state
    Server->>Server: validate CSRF state (timing-safe)
    Server->>Stripe: oauth.token exchange (code -> stripe_user_id)
    Stripe-->>Server: stripe_user_id
    Server->>Stripe: retrieve account
    Stripe-->>Server: account object
    Server->>DB: update affiliate stripe status, charges/payouts flags, lastSync
    Server->>Browser: redirect to affiliate dashboard
Loading
sequenceDiagram
    autonumber
    participant Cron as AutoPayout Worker
    participant DA as Data-Access
    participant DB as Database
    participant Stripe as Stripe API
    participant Email as Email Service

    Cron->>DA: getEligibleAffiliatesForAutoPayout()
    DA->>DB: query unpaid balances >= min
    DB-->>DA: affiliates[]
    DA-->>Cron: affiliates[]

    loop per affiliate
        Cron->>DB: create payout record status='pending'
        Cron->>Stripe: create Transfer (to affiliate account)
        alt transfer succeeds
            Stripe-->>Cron: transfer_id
            Cron->>DB: update payout status='completed', stripeTransferId
            Cron->>Email: sendAffiliatePayoutSuccessEmail
        else transfer fails
            Stripe-->>Cron: error
            Cron->>DB: update payout status='failed', errorMessage
            Cron->>Email: sendAffiliatePayoutFailedEmail
        end
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related PRs

Poem

🐰 I hopped through routes and signed my state,
Found shiny Stripe doors and a secure gate,
Payouts now hum while tables neatly sort,
Cookies and tokens safe in my short fort,
A carrot-shaped PR — let's celebrate! 🎉

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.74% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Affiliate Program with Stripe Connect integration' is a concise, single-sentence description that clearly summarizes the main change—the addition of an affiliate program with Stripe Connect integration. It covers the primary objective of the PR without being overly broad or vague.

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

Copy link

@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: 20

Caution

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

⚠️ Outside diff range comments (2)
src/routes/api/login/google/callback/index.ts (1)

86-86: Tighten error type checking for redirect detection.

The check e && typeof e === "object" && "to" in e is too permissive. It will catch any object with a to property, not specifically TanStack router redirect errors. This could result in unintended re-throws if other error objects happen to have a to property.

Consider using a more explicit check, such as testing against a specific error class or using instanceof with the actual redirect error type.

src/routes/admin/affiliates.tsx (1)

71-76: Hardcoded minimum may conflict with configurable setting.

The payout schema hardcodes a $50 minimum, but there's a configurable minimumPayout setting loaded via getAffiliateMinimumPayoutFn. If an admin sets a different minimum, the form validation won't reflect it.

Consider making the schema dynamic or aligning the hardcoded value with the configuration.

♻️ Duplicate comments (1)
src/routes/admin/-affiliates-components/affiliates-columns.tsx (1)

42-47: Duplicate helper - see earlier recommendation.

This formatCurrency helper is duplicated across multiple files. See the recommended refactor in affiliate-details-sheet.tsx to consolidate into a shared utility.

🧹 Nitpick comments (52)
src/routes/api/login/google/callback/index.ts (2)

27-27: Consider using timing-safe comparison for state validation.

The state parameter is compared using a standard string inequality check. Given that the PR adds timing-safe string comparison utilities as an infrastructure improvement, consider applying the same pattern here to prevent timing-based attacks during OAuth state validation.

Can you verify whether a timing-safe comparison utility has been added in this PR and should be applied to the OAuth state check?


90-90: Add context to error logging.

The console.error(e) lacks context about which operation failed (e.g., OAuth token validation, Google user info fetch, or database lookup). Adding context will improve observability during debugging.

src/routes/profile/$userId.tsx (1)

300-300: Improve React key stability by using a delimiter.

Using index+tech concatenates the values as strings (e.g., "0React"), which could theoretically create collisions if tech names start with digits. While unlikely in practice, a more robust approach would improve key uniqueness.

🔎 Suggested improvement
-                                    key={index+tech}
+                                    key={`${index}-${tech}`}

Or if technologies are guaranteed unique within a project:

-                                    key={index+tech}
+                                    key={tech}
src/data-access/profiles.ts (1)

15-28: LGTM: Well-designed helper for presigned URL generation.

The generateImageUrl helper properly:

  • Provides backward compatibility with the fallback pattern
  • Handles errors gracefully
  • Returns null for missing imageIds

Consider using a structured logger instead of console.error on Line 25, especially since the PR introduces "scoped server function logger" infrastructure. This would provide better observability and consistent logging patterns.

🔎 Optional improvement
+import { createLogger } from '~/lib/logger'; // Adjust path as needed
+
+const logger = createLogger('profiles');
+
 async function generateImageUrl(
   imageId: string | null,
   fallbackImage: string | null
 ): Promise<string | null> {
   if (!imageId) return fallbackImage;
   try {
     const { storage } = getStorage();
     return await storage.getPresignedUrl(imageId);
   } catch (error) {
-    console.error(`Failed to generate presigned URL for ${imageId}:`, error);
+    logger.error(`Failed to generate presigned URL for ${imageId}`, { error, imageId });
     return fallbackImage;
   }
 }
src/routes/learn/$slug/-components/comment-form.tsx (1)

132-135: Avatar error handling looks good.

The onError handler correctly prevents infinite loops and provides a graceful fallback to the Dicebear avatar service.

However, the Dicebear URL construction is duplicated between the initial src (line 129) and the onError fallback (line 134). Consider extracting this logic:

🔎 Suggested refactor to reduce duplication
 export function CommentForm({ autoFocus = false }: { autoFocus?: boolean }) {
   const [commentText, setCommentText] = useState("");
   const [isFocused, setIsFocused] = useState(false);
   const textareaRef = useRef<HTMLTextAreaElement>(null);
   const user = useAuth();
   const { segment } = useLoaderData({ from: "/learn/$slug/_layout/" });
   const { mutate: createComment, isPending } = useCreateComment();
   const { data: profile } = useProfile();
+
+  const fallbackAvatarUrl = useMemo(() => {
+    const seed = (profile?.useDisplayName === false && profile?.realName) 
+      ? profile.realName 
+      : (profile?.displayName || "user");
+    return `https://api.dicebear.com/9.x/initials/svg?seed=${seed}&backgroundColor=6366f1&textColor=ffffff`;
+  }, [profile?.useDisplayName, profile?.realName, profile?.displayName]);

   // ... rest of component

   <img
     className="max-h-10 w-auto object-cover"
-    src={
-      profile?.image ??
-      `https://api.dicebear.com/9.x/initials/svg?seed=${(profile?.useDisplayName === false && profile?.realName) ? profile.realName : (profile?.displayName || "user")}&backgroundColor=6366f1&textColor=ffffff`
-    }
+    src={profile?.image ?? fallbackAvatarUrl}
     alt="Your avatar"
     onError={(e) => {
       e.currentTarget.onerror = null;
-      e.currentTarget.src = `https://api.dicebear.com/9.x/initials/svg?seed=${(profile?.useDisplayName === false && profile?.realName) ? profile.realName : (profile?.displayName || "user")}&backgroundColor=6366f1&textColor=ffffff`;
+      e.currentTarget.src = fallbackAvatarUrl;
     }}
   />

This approach:

  • Eliminates duplication
  • Improves readability
  • Memoizes the URL to avoid recalculation on every render
src/routes/learn/$slug/-components/comment-list.tsx (2)

199-202: Avatar fallback correctly implemented.

The error handler follows the same safe pattern as in comment-form.tsx.

The Dicebear URL is duplicated between lines 196 and 201. Consider extracting to a helper function or constant at the module level:

🔎 Suggested refactor

Add a helper function at the top of the file:

const getDicebearUrl = (seed: string) => 
  `https://api.dicebear.com/9.x/initials/svg?seed=${seed}&backgroundColor=6366f1&textColor=ffffff`;

Then simplify the avatar code:

+const fallbackUrl = getDicebearUrl(comment.profile.publicName || "User");
+
 <img
   className="max-h-10 w-auto object-cover"
-  src={
-    comment.profile.image ??
-    `https://api.dicebear.com/9.x/initials/svg?seed=${comment.profile.publicName || "User"}&backgroundColor=6366f1&textColor=ffffff`
-  }
+  src={comment.profile.image ?? fallbackUrl}
   alt="User avatar"
   onError={(e) => {
     e.currentTarget.onerror = null;
-    e.currentTarget.src = `https://api.dicebear.com/9.x/initials/svg?seed=${comment.profile.publicName || "User"}&backgroundColor=6366f1&textColor=ffffff`;
+    e.currentTarget.src = fallbackUrl;
   }}
 />

367-370: Error handling implemented correctly.

The onError pattern is consistent with the other avatar implementations in this file.

The Dicebear URL construction is duplicated here as well (lines 364 and 369). Applying the same helper function suggested in the previous comment would improve maintainability:

🔎 Suggested refactor

Using the same getDicebearUrl helper:

+const replyFallbackUrl = getDicebearUrl(user?.id || "user");
+
 <img
   className="max-h-8 w-auto object-cover"
   src={
     comment.profile.image ??
-    `https://api.dicebear.com/9.x/initials/svg?seed=${user?.id || "user"}&backgroundColor=6366f1&textColor=ffffff`
+    replyFallbackUrl
   }
   alt="Your avatar"
   onError={(e) => {
     e.currentTarget.onerror = null;
-    e.currentTarget.src = `https://api.dicebear.com/9.x/initials/svg?seed=${user?.id || "user"}&backgroundColor=6366f1&textColor=ffffff`;
+    e.currentTarget.src = replyFallbackUrl;
   }}
 />

Note: If you implement this refactor across all three avatar locations (lines 199-202 and 367-370 in this file, plus comment-form.tsx), consider creating a shared utility function in a common location (e.g., ~/lib/utils.ts) to ensure consistency across the codebase.

src/hooks/use-debounced-callback.ts (1)

1-28: Consider adding "use client" directive for SSR safety.

The hook uses window.setTimeout and window.clearTimeout, which are browser-only APIs. If this hook is imported in a module that could be server-rendered, it may cause issues.

While the implementation logic is correct, consider adding "use client" at the top of the file to explicitly mark this as a client-only hook, or verify that it's only consumed in client components.

🔎 Suggested addition
+"use client";
+
 import * as React from "react";
 
 import { useCallbackRef } from "~/hooks/use-callback-ref";
src/components/data-table/data-table-column-header.tsx (1)

63-87: Use DropdownMenuItem for sort actions instead of DropdownMenuCheckboxItem.

Lines 63-78 use DropdownMenuCheckboxItem with onClick handlers for sort actions. While this works, checkbox items are semantically intended for toggling boolean state with onCheckedChange. The sort actions (ascending/descending) are mutually exclusive choices, not independent toggles.

The checked visual indicator is appropriate here, but consider using regular DropdownMenuItem with custom styling for the checkmark, or document why checkbox items are preferred for this UX pattern.

src/components/emails/affiliate-payout-success-email.tsx (1)

15-20: Interface duplication with src/utils/email.ts.

This interface duplicates AffiliatePayoutSuccessEmailProps defined at src/utils/email.ts:298-303. Consider importing the shared type to maintain a single source of truth, though this is a minor concern for email components.

src/features/affiliates/README.md (1)

1-1: Consider expanding or consolidating this placeholder.

This README only contains a header. If comprehensive documentation exists at docs/features/affiliates/readme.md, consider either expanding this file with feature-specific context or removing it to avoid documentation fragmentation.

src/components/discount-dialog.tsx (1)

74-76: Hardcoded discount percentage in UI text.

The dialog description mentions "10% off" but affiliate discount rates are now configurable. Consider making this dynamic or removing the specific percentage.

🔎 Suggested improvement
           <DialogDescription>
-            Enter your affiliate discount code to get 10% off your purchase
+            Enter your affiliate discount code to get a discount on your purchase
           </DialogDescription>
drizzle/0051_chilly_the_watchers.sql (1)

1-1: Minor formatting issue: Inline statement breakpoint.

The statement breakpoint marker is placed inline with the DROP INDEX statement rather than on its own line. While this may not affect functionality, it's inconsistent with typical Drizzle migration formatting where breakpoints are on separate lines.

🔎 Suggested format
-DROP INDEX "affiliates_code_idx";--> statement-breakpoint
+DROP INDEX "affiliates_code_idx";
+--> statement-breakpoint
.env.sample (1)

12-12: Inline comment may cause parsing issues.

The inline comment after the value on Line 12 may be interpreted as part of the environment variable value by some .env parsers, leading to unexpected behavior. Standard practice is to place comments on their own line above the variable.

🔎 Suggested format
-STRIPE_PRICE_ID=your_stripe_price_id # likely no longer needed as we use dynamic payment intent
+# Likely no longer needed as we use dynamic payment intent
+STRIPE_PRICE_ID=your_stripe_price_id
src/utils/logger.ts (1)

8-38: Consider protecting reserved log fields from context collision.

If context contains keys like level, timestamp, or message, they will override the structured log fields due to spread order. Consider spreading context first or filtering reserved keys.

🔎 Proposed fix to protect reserved fields
 export const logger = {
   info: (message: string, context?: LogContext) => {
+    const { level: _, timestamp: __, message: ___, ...safeContext } = context ?? {};
     console.log(
       JSON.stringify({
         level: "info",
         timestamp: new Date().toISOString(),
         message,
-        ...context,
+        ...safeContext,
       })
     );
   },
   // Apply same pattern to warn and error...

Alternatively, spread context first so structured fields always take precedence:

     console.log(
       JSON.stringify({
+        ...context,
         level: "info",
         timestamp: new Date().toISOString(),
         message,
-        ...context,
       })
     );
src/components/emails/affiliate-payout-failed-email.tsx (1)

17-21: Duplicate interface definition.

AffiliatePayoutFailedEmailProps is already defined in src/utils/email.ts (lines 330-334 per the relevant snippets). Consider importing it to maintain a single source of truth.

🔎 Proposed fix
-interface AffiliatePayoutFailedEmailProps {
-  affiliateName: string;
-  errorMessage: string;
-  failureDate: string;
-}
+import type { AffiliatePayoutFailedEmailProps } from "~/utils/email";
src/components/data-table/data-table-date-filter.tsx (1)

206-227: Minor inconsistency: autoFocus only on range Calendar.

The range mode Calendar has autoFocus (line 208) but the single-date Calendar (line 219) does not. Consider adding autoFocus to both for consistent UX.

🔎 Proposed fix
         ) : (
           <Calendar
+            autoFocus
             captionLayout="dropdown"
             mode="single"
src/routes/api/connect/stripe/oauth/index.ts (1)

10-10: Consider consolidating the cookie expiration constant.

The constant MAX_COOKIE_AGE_SECONDS = 60 * 10 is duplicated in both src/routes/api/connect/stripe/oauth/index.ts and src/routes/api/connect/stripe/index.ts. Consider moving this to a shared location (e.g., src/config.ts) to maintain consistency.

As per coding guidelines, avoid magic numbers and consolidate them to configuration files.

src/routes/api/connect/stripe/index.ts (1)

104-104: Consider using structured logging.

The PR summary mentions "scoped server function logger" as part of infrastructure changes. Consider using the structured logger instead of console.error for consistency and better observability.

Based on the PR objectives mentioning infrastructure changes including scoped server function logger.

src/components/blocks/number-input-with-controls.tsx (1)

59-59: Consider supporting decimal values.

The regex /^\d+$/ only allows positive integers. If decimal values are needed in the future (e.g., for percentage inputs with fractional values), this validation would need to be updated. Document this limitation or consider making it configurable.

src/components/data-table/data-table-slider-filter.tsx (1)

104-122: Verify range boundary validation in input handlers.

The input change handlers validate against min/max and the current range, but there's potential for edge cases:

  • Line 107: numValue >= min && numValue <= range[1] - doesn't explicitly check against max
  • Line 117: numValue <= max && numValue >= range[0] - doesn't explicitly check against min

While the conditions might work in most cases, explicitly checking both min/max and the current range bounds would make the validation clearer and more robust.

🔎 Suggested refinement
 const onFromInputChange = React.useCallback(
   (event: React.ChangeEvent<HTMLInputElement>) => {
     const numValue = Number(event.target.value);
-    if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) {
+    if (!Number.isNaN(numValue) && numValue >= min && numValue <= Math.min(max, range[1])) {
       column.setFilterValue([numValue, range[1]]);
     }
   },
-  [column, min, range],
+  [column, min, max, range],
 );

 const onToInputChange = React.useCallback(
   (event: React.ChangeEvent<HTMLInputElement>) => {
     const numValue = Number(event.target.value);
-    if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) {
+    if (!Number.isNaN(numValue) && numValue >= Math.max(min, range[0]) && numValue <= max) {
       column.setFilterValue([range[0], numValue]);
     }
   },
-  [column, max, range],
+  [column, min, max, range],
 );
src/routes/admin/affiliates.tsx (2)

248-255: Potential stale closure in dependency array.

The effect references selectedAffiliate in its body but the dependency array uses selectedAffiliate?.id. While this works for the find logic, if other properties of selectedAffiliate were used, they could be stale.

Consider using the full object in the dependency array or extracting the ID to a separate variable:

🔎 Suggested improvement
-  }, [affiliates, selectedAffiliate?.id]);
+  }, [affiliates, selectedAffiliate]);

234-239: Consider extracting magic numbers to constants.

The debounce timeout (300ms) and other configuration values like page size (10) are hardcoded. Per coding guidelines, consider consolidating these to the top of the file or in the config.

🔎 Example extraction
// At top of file
const SEARCH_DEBOUNCE_MS = 300;
const DEFAULT_PAGE_SIZE = 10;
src/lib/server-logger.ts (1)

64-70: Clever export pattern - consider adding JSDoc for clarity.

The Object.assign pattern allows both logMiddleware("payments") and direct use as middleware. While effective, this dual-nature API may be non-obvious to consumers.

🔎 Suggested documentation
+/**
+ * Scoped logging middleware factory.
+ * Usage:
+ *   - As factory: logMiddleware("payments") - creates middleware for "payments" scope
+ *   - As default: logMiddleware (direct) - uses default scope
+ */
 export const logMiddleware = Object.assign(
   (scope: LogScope) => createLogMiddlewareForScope(scope),
   defaultMiddleware
 );
src/components/ui/button-group.tsx (1)

38-52: Consider adding default accessibility attributes.

The InputGroupInput lacks common accessibility attributes. Consider adding sensible defaults that can be overridden:

🔎 Suggested enhancement
 function InputGroupInput({
   className,
+  type = "text",
   ...props
 }: React.ComponentProps<"input">) {
   return (
     <input
       data-slot="input-group-input"
+      type={type}
       className={cn(
         "flex-1 bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 min-w-0",
         className
       )}
       {...props}
     />
   )
 }
src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx (1)

43-68: Duplicate formatting helpers - consider extracting to shared utility.

formatCurrency, formatDate, and formatShortDate are duplicated here and in affiliates.tsx. These could be consolidated into a shared utility (e.g., ~/lib/format.ts mentioned in the AI summary).

🔎 Suggested extraction
// ~/lib/format.ts
export const formatCurrency = (cents: number) => {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
  }).format(cents / 100);
};

export const formatDate = (date: Date | string | null) => {
  if (!date) return "N/A";
  return new Date(date).toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
  });
};
src/routes/admin/-affiliates-components/affiliates-columns.tsx (1)

50-56: Unused callback options in AffiliateColumnsOptions.

The onCopyLink and onViewLink callbacks are defined in the interface but not used in any column definitions. If these are planned for future use, consider adding a TODO comment; otherwise, remove them to avoid dead code.

#!/bin/bash
# Verify if onCopyLink and onViewLink are used anywhere in the column definitions
rg -n "onCopyLink|onViewLink" src/routes/admin/-affiliates-components/affiliates-columns.tsx
src/use-cases/app-settings.ts (1)

158-176: Consider atomic transactions for multi-field updates.

updatePricingSettingsUseCase updates three settings concurrently via Promise.all. If one update fails, the others may succeed, leaving settings in an inconsistent state. Consider wrapping these in a transaction at the data-access layer or handling partial failures.

🔎 Proposed approach

Either handle partial failures gracefully:

 export async function updatePricingSettingsUseCase(settings: {
   currentPrice?: number;
   originalPrice?: number;
   promoLabel?: string;
 }): Promise<void> {
-  const updates: Promise<void>[] = [];
+  const results = await Promise.allSettled([
+    settings.currentPrice !== undefined ? setCurrentPriceInDb(settings.currentPrice) : Promise.resolve(),
+    settings.originalPrice !== undefined ? setOriginalPriceInDb(settings.originalPrice) : Promise.resolve(),
+    settings.promoLabel !== undefined ? setPromoLabelInDb(settings.promoLabel) : Promise.resolve(),
+  ]);
 
-  if (settings.currentPrice !== undefined) {
-    updates.push(setCurrentPriceInDb(settings.currentPrice));
-  }
-  if (settings.originalPrice !== undefined) {
-    updates.push(setOriginalPriceInDb(settings.originalPrice));
-  }
-  if (settings.promoLabel !== undefined) {
-    updates.push(setPromoLabelInDb(settings.promoLabel));
-  }
-
-  await Promise.all(updates);
+  const failures = results.filter(r => r.status === 'rejected');
+  if (failures.length > 0) {
+    throw new Error(`Failed to update ${failures.length} pricing setting(s)`);
+  }
 }

Or create a batch update function in data-access that uses a database transaction.

src/routes/api/connect/stripe/callback/index.ts (1)

39-123: Inconsistent indentation in GET handler body.

The handler body (lines 43-119) uses different indentation than the outer structure. This appears to be a formatting issue where the handler content is not properly nested.

🔎 Proposed fix
 export const Route = createFileRoute("/api/connect/stripe/callback/")({
   server: {
     handlers: {
       GET: async ({ request }) => {
-    const url = new URL(request.url);
-    const state = url.searchParams.get("state");
+        const url = new URL(request.url);
+        const state = url.searchParams.get("state");
         // ... rest of handler with consistent 8-space indentation
src/routes/admin/pricing.tsx (3)

24-33: Loader should return the ensureQueryData promise.

The loader calls ensureQueryData but doesn't return its result. While TanStack Router may handle this, returning the promise ensures proper data loading behavior.

🔎 Proposed fix
   loader: ({ context }) => {
-    context.queryClient.ensureQueryData({
+    return context.queryClient.ensureQueryData({
       queryKey: ["pricingSettings"],
       queryFn: () => getPricingSettingsFn(),
     });
   },

174-184: Use shadcn Button component instead of custom styled button.

Per coding guidelines, component styling should follow shadcn/ui patterns. Replace the custom <button> with the shadcn Button component.

🔎 Proposed fix

Add import:

 import { Input } from "~/components/ui/input";
+import { Button } from "~/components/ui/button";

Replace button:

-            <button
-              onClick={() => updatePromoLabelMutation.mutate(promoLabel)}
-              disabled={!hasPromoLabelChanges || updatePromoLabelMutation.isPending}
-              className="h-10 px-3 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none"
-            >
-              {updatePromoLabelMutation.isPending ? (
-                <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
-              ) : (
-                "Save"
-              )}
-            </button>
+            <Button
+              variant="outline"
+              onClick={() => updatePromoLabelMutation.mutate(promoLabel)}
+              disabled={!hasPromoLabelChanges || updatePromoLabelMutation.isPending}
+            >
+              {updatePromoLabelMutation.isPending ? (
+                <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
+              ) : (
+                "Save"
+              )}
+            </Button>

241-246: Extract magic number to a constant.

The exampleAffiliateDiscount = 12 is a magic number used for preview calculations. Per coding guidelines, consolidate magic numbers to the top of the file or config.

🔎 Proposed fix

At the top of the file after imports:

+const EXAMPLE_AFFILIATE_DISCOUNT_PERCENT = 12;

In the component:

-              const exampleAffiliateDiscount = 12;
+              const exampleAffiliateDiscount = EXAMPLE_AFFILIATE_DISCOUNT_PERCENT;

Based on coding guidelines: "Never hard code magic numbers into code; consolidate them to the top of the file or in /src/config/index.ts"

src/routes/admin/feature-flags.tsx (2)

46-52: Loader should return the ensureQueryData promise.

Similar to the pricing route, the loader calls ensureQueryData but doesn't return its result.

🔎 Proposed fix
   loader: ({ context }) => {
-    // Prefetch all feature flags in one request
-    context.queryClient.ensureQueryData({
+    // Prefetch all feature flags in one request
+    return context.queryClient.ensureQueryData({
       queryKey: ["allFeatureFlags"],
       queryFn: () => getAllFeatureFlagsFn(),
     });
   },

180-181: Mutable animation index in render is fragile.

Using a mutable let variable that's incremented during render works but is fragile. Consider computing the index based on the flat array position.

🔎 Proposed fix using flatMap index
-  // Track global animation index across groups
-  let animationIndex = 0;
   ...
-        {filteredAndGroupedFlags.flatMap((groupData) =>
-          groupData.flags.map((flag) => {
-            const currentIndex = animationIndex++;
+        {filteredAndGroupedFlags.flatMap((groupData, groupIdx) =>
+          groupData.flags.map((flag, flagIdx) => {
+            // Calculate global index based on previous groups
+            const previousFlagsCount = filteredAndGroupedFlags
+              .slice(0, groupIdx)
+              .reduce((sum, g) => sum + g.flags.length, 0);
+            const currentIndex = previousFlagsCount + flagIdx;
             return (

Or pre-compute a flat array with indices before rendering.

src/utils/crypto.ts (1)

70-81: Consider constant-time length comparison for signature verification.

The length check at lines 76-78 can leak timing information about whether the signature length is correct. While the actual signature comparison is timing-safe, an attacker could determine length discrepancies.

🔎 Alternative approach

For highest security, you could pad both buffers to a fixed length before comparison, though this is likely overkill for this use case since base64url signatures have predictable lengths.

 export function verifySignature(data: string, signature: string): boolean {
   const expectedSignature = signData(data);
-  // Use timing-safe comparison to prevent timing attacks
-  const sigBuffer = Buffer.from(signature, "base64url");
-  const expectedBuffer = Buffer.from(expectedSignature, "base64url");
-
-  if (sigBuffer.length !== expectedBuffer.length) {
-    return false;
-  }
-
-  return timingSafeEqual(sigBuffer, expectedBuffer);
+  // Compare the base64url strings directly after computing expected
+  return timingSafeStringEqual(signature, expectedSignature);
 }

This leverages your existing timingSafeStringEqual which handles length differences safely.

src/hooks/use-data-table.ts (1)

134-139: Clear pagination state mapping with helpful comment.

The comment on line 136 explains the index conversion well. One minor note: the comment says "zero-based index -> one-based index" but the code does the reverse (subtracting 1 converts one-based to zero-based).

🔎 Proposed fix for comment clarity
   const pagination: PaginationState = React.useMemo(() => {
     return {
-      pageIndex: page - 1, // zero-based index -> one-based index
+      pageIndex: page - 1, // Convert one-based URL param to zero-based table index
       pageSize: perPage,
     };
   }, [page, perPage]);
src/routes/api/connect/stripe/oauth/callback/index.ts (1)

102-113: Validate stripe.oauth.token response thoroughly.

The stripe.oauth.token call returns more than just stripe_user_id. Consider logging the response type or checking for other error indicators in the response before proceeding.

🔎 Enhanced validation
           // Exchange authorization code for connected account ID
           const response = await stripe.oauth.token({
             grant_type: "authorization_code",
             code,
           });

           const connectedAccountId = response.stripe_user_id;

           if (!connectedAccountId) {
-            console.error("No stripe_user_id in OAuth response");
+            console.error("No stripe_user_id in OAuth response:", {
+              hasAccessToken: !!response.access_token,
+              tokenType: response.token_type,
+              scope: response.scope,
+            });
             return new Response("Failed to connect Stripe account", { status: 500 });
           }
src/components/ui/calendar.tsx (1)

180-216: CalendarDayButton ignores the parent's buttonVariant prop.

The Calendar component accepts a buttonVariant prop (line 21) and uses it for the navigation buttons (lines 57, 62), but CalendarDayButton always renders with variant="ghost" hardcoded at line 196. If the intent is to allow consistent theming, consider passing the variant through to CalendarDayButton via the components prop or modifiers.

Additionally, the className string at line 209 is quite long. Consider extracting it to a variable for readability.

src/components/data-table/data-table-faceted-filter.tsx (1)

86-100: Consider using a <button> element instead of <span role="button">.

For better accessibility and native behavior, prefer a semantic <button> element over a <span> with role="button". Native buttons handle keyboard events and focus management automatically.

🔎 Proposed fix
-          {selectedValues?.size > 0 ? (
-            <span
-              role="button"
-              tabIndex={0}
-              aria-label={`Clear ${title} filter`}
-              className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
-              onClick={onReset}
-              onKeyDown={(e) => {
-                if (e.key === "Enter" || e.key === " ") {
-                  e.preventDefault();
-                  onReset();
-                }
-              }}
-            >
-              <XCircle />
-            </span>
+          {selectedValues?.size > 0 ? (
+            <button
+              type="button"
+              aria-label={`Clear ${title} filter`}
+              className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
+              onClick={onReset}
+            >
+              <XCircle />
+            </button>
src/lib/data-table.ts (1)

24-24: Extract magic number to a named constant.

The value 150 (TanStack default column size) is a magic number. Per coding guidelines, consolidate magic numbers to the top of the file or in /src/config/index.ts.

🔎 Proposed fix
+const TANSTACK_DEFAULT_COLUMN_SIZE = 150;
+
 export function getCommonPinningStyles<TData>({
   column,
   withBorder = false,
 }: {
   column: Column<TData>;
   withBorder?: boolean;
 }): React.CSSProperties {
   const isPinned = column.getIsPinned();
   // ...
   const columnDef = column.columnDef;
-  const hasExplicitSize = columnDef.size !== undefined && columnDef.size !== 150; // 150 is TanStack default
+  const hasExplicitSize = columnDef.size !== undefined && columnDef.size !== TANSTACK_DEFAULT_COLUMN_SIZE;
src/components/data-table/data-table-skeleton.tsx (1)

58-72: Simplify single-row header generation.

Array.from({ length: 1 }).map(...) is unnecessary for generating a single element. Render the TableRow directly.

🔎 Proposed fix
           <TableHeader>
-            {Array.from({ length: 1 }).map((_, i) => (
-              <TableRow key={i} className="hover:bg-transparent">
-                {Array.from({ length: columnCount }).map((_, j) => (
-                  <TableHead
-                    key={j}
-                    style={{
-                      width: cozyCellWidths[j],
-                      minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
-                    }}
-                  >
-                    <Skeleton className="h-6 w-full" />
-                  </TableHead>
-                ))}
-              </TableRow>
-            ))}
+            <TableRow className="hover:bg-transparent">
+              {Array.from({ length: columnCount }).map((_, j) => (
+                <TableHead
+                  key={j}
+                  style={{
+                    width: cozyCellWidths[j],
+                    minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
+                  }}
+                >
+                  <Skeleton className="h-6 w-full" />
+                </TableHead>
+              ))}
+            </TableRow>
           </TableHeader>
src/utils/email.ts (1)

306-328: Consider caching the rendered email to avoid double rendering.

The email template is rendered twice — once for HTML and once for plain text. While the overhead is likely minimal, you could refactor to render both in a single pass if performance becomes a concern.

🔎 Proposed optimization
 export async function sendAffiliatePayoutSuccessEmail(
   to: string,
   props: AffiliatePayoutSuccessEmailProps
 ): Promise<void> {
   try {
-    const html = await render(AffiliatePayoutSuccessEmail(props));
-    const text = await render(AffiliatePayoutSuccessEmail(props), {
-      plainText: true,
-    });
+    const emailElement = AffiliatePayoutSuccessEmail(props);
+    const [html, text] = await Promise.all([
+      render(emailElement),
+      render(emailElement, { plainText: true }),
+    ]);
     await sendEmail({
       to,
       subject: `Your affiliate payout of ${props.payoutAmount} has been sent!`,
       html,
       text,
     });
src/routes/affiliates.tsx (1)

286-305: Extract hardcoded metrics to configuration.

The values "12%" (conversion rate) and "98%" (customer satisfaction) are hardcoded marketing metrics. Per coding guidelines, consider moving magic numbers to config or clearly documenting them as static marketing copy if they're not meant to be dynamic.

docs/features/affiliates/readme.md (1)

452-460: Add language specifiers to fenced code blocks.

Per static analysis (markdownlint MD040), these flow diagram code blocks should have a language specifier. Since they're ASCII diagrams, use text or plaintext:

Suggested fix
-```
+```text
 User clicks "Connect" → /api/connect/stripe (creates account, generates link)

Apply similar changes to lines 532 and 537.

src/fn/affiliates.ts (2)

22-22: Data-access functions imported directly into server function file.

Per coding guidelines, server functions should invoke use-cases rather than data-access functions directly. The imports getAffiliateByUserId, updateAffiliateDiscountRate, getAffiliatePayouts, and getAffiliateReferrals are data-access layer functions that should be wrapped in use-cases.

Consider creating use-case wrappers like updateAffiliateDiscountRateUseCase, getAffiliatePayoutsUseCase, and getAffiliateReferralsUseCase in ~/use-cases/affiliates.ts.


216-222: Consider extracting magic number to config.

The minimum payout value 5000 is hardcoded here. Per coding guidelines, magic numbers should be consolidated to config files. This value should reference AFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUT or be fetched from app settings for consistency.

Suggested fix
+import { AFFILIATE_CONFIG } from "~/config";
+
 const recordPayoutSchema = z.object({
   affiliateId: z.number(),
-  amount: z.number().min(5000, "Minimum payout is $50"),
+  amount: z.number().min(AFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUT, `Minimum payout is $${AFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUT / 100}`),
   paymentMethod: z.string().min(1, "Payment method is required"),
   transactionId: z.string().optional(),
   notes: z.string().optional(),
 });
src/routes/affiliate-onboarding.tsx (2)

72-75: Consider adding error boundary for route-level error handling.

Per coding guidelines, routes should implement error boundaries using DefaultCatchBoundary. This helps provide a consistent error experience if the loader or component fails.

Suggested addition
import { DefaultCatchBoundary } from "~/components/default-catch-boundary";

export const Route = createFileRoute("/affiliate-onboarding")({
  validateSearch: searchSchema,
  beforeLoad: () => assertFeatureEnabled("AFFILIATES_FEATURE"),
  loader: async ({ context }) => { /* ... */ },
  component: AffiliateOnboarding,
  errorComponent: DefaultCatchBoundary,
});

194-199: Dynamic import could be a static import.

The updatePaymentMethodFn is dynamically imported but it's from the same module (~/fn/affiliates) where registerAffiliateFn and checkIfUserIsAffiliateFn are already statically imported. Consider adding it to the existing static imports for consistency and to avoid the async overhead.

Suggested fix
 import {
   registerAffiliateFn,
   checkIfUserIsAffiliateFn,
+  updatePaymentMethodFn,
 } from "~/fn/affiliates";

 // Then in the mutation:
 const updatePaymentMutation = useMutation({
   mutationFn: async (paymentLink: string) => {
-    const { updatePaymentMethodFn } = await import("~/fn/affiliates");
     return updatePaymentMethodFn({
       data: { paymentMethod: "link", paymentLink },
     });
   },
src/routes/api/stripe/webhook.ts (1)

267-293: Consider using stripe-event-types or manual type narrowing instead of string casting.

The transfer.paid and transfer.failed events are valid Stripe event types, but Stripe's official TypeScript types don't discriminate webhook events by default. While casting event.type to string works functionally, better approaches exist:

  1. Install the stripe-event-types community package for discriminated webhook event types
  2. Manually define narrow types:
type TransferPaidEvent = Stripe.Event & {
  type: "transfer.paid";
  data: { object: Stripe.Transfer };
};

These approaches provide proper TypeScript type narrowing and eliminate the need for casting.

src/routes/affiliate-dashboard.tsx (2)

1-1210: File exceeds 1000-line limit.

This file is approximately 1210 lines, exceeding the 1000-line guideline. Consider extracting components into separate files:

  • Animation variants → ~/lib/animations.ts
  • Stats cards → ~/components/affiliate/stats-grid.tsx
  • Payment information card → ~/components/affiliate/payment-info-card.tsx
  • Discount share card → ~/components/affiliate/discount-share-card.tsx

As per coding guidelines, split overly large files into smaller modular components to maintain code maintainability.


221-226: Non-null assertion may mask loader issues.

Using dashboard! assumes the loader always provides data, but TypeScript's non-null assertion bypasses safety checks. While the beforeLoad guard redirects non-affiliates, consider a defensive check or use a type guard to maintain type safety.

-  const dashboard = loaderData.dashboard!;
+  const dashboard = loaderData.dashboard;
+  if (!dashboard) {
+    // This should never happen due to beforeLoad guard, but provides type safety
+    throw new Error("Dashboard data not available");
+  }
src/db/schema.ts (1)

344-348: Enum definition placed after table that uses it.

The affiliatePayoutStatusEnum is defined after the affiliateReferrals table but before affiliatePayouts which uses it. While this works due to Drizzle's hoisting, consider grouping all enums together at the top of the file for better organization and consistency with stripeAccountStatusEnum and affiliatePaymentMethodEnum.

className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
const isRightAligned = className?.includes("ml-auto");
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Replace fragile string-based alignment detection with explicit prop.

Line 33 checks for the presence of "ml-auto" in the className string to determine right alignment. This is fragile and tightly couples the component to specific Tailwind class names. If alignment is achieved through different means (e.g., justify-end, flexbox, or custom classes), this check will fail.

🔎 Proposed fix

Add an explicit align prop to the interface:

 interface DataTableColumnHeaderProps<TData, TValue>
   extends React.ComponentProps<typeof DropdownMenuTrigger> {
   column: Column<TData, TValue>;
   label: string;
+  align?: "start" | "end";
 }
 
 export function DataTableColumnHeader<TData, TValue>({
   column,
   label,
   className,
+  align = "start",
   ...props
 }: DataTableColumnHeaderProps<TData, TValue>) {
-  const isRightAligned = className?.includes("ml-auto");
+  const isRightAligned = align === "end";
🤖 Prompt for AI Agents
In src/components/data-table/data-table-column-header.tsx around line 33, the
component currently infers right alignment by checking
className.includes("ml-auto"), which is fragile; add an explicit align prop
(e.g., align?: 'left' | 'center' | 'right' with default 'left') to the component
props/interface, replace the className string check with logic that uses the
align prop to set isRightAligned (isRightAligned = align === 'right'), update
the component consumers/call sites to pass align where appropriate (or allow
default), and remove the hard dependency on the "ml-auto" check (you may keep it
as a temporary fallback only if align is undefined to preserve backward
compatibility while migrating callers).

Comment on lines +90 to +99
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={!column.getIsVisible()}
onClick={() => column.toggleVisibility(false)}
>
<EyeOff />
Hide
</DropdownMenuCheckboxItem>
)}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix inverted hide/show logic for better UX.

Line 93 checks !column.getIsVisible() to set the checked state, and line 94 calls toggleVisibility(false) on click. This means:

  • When the column is visible → checkbox is unchecked → clicking hides it ✓
  • When the column is hidden → checkbox is checked → clicking... also hides it? ✗

The UX is confusing because:

  1. A checked "Hide" item implies the column is hidden, but clicking it doesn't show it
  2. The toggle always passes false, so it can only hide, never show
🔎 Proposed fix
 {column.getCanHide() && (
   <DropdownMenuCheckboxItem
     className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
-    checked={!column.getIsVisible()}
-    onClick={() => column.toggleVisibility(false)}
+    checked={column.getIsVisible()}
+    onClick={() => column.toggleVisibility()}
   >
     <EyeOff />
-    Hide
+    Visible
   </DropdownMenuCheckboxItem>
 )}

Or keep "Hide" but invert the logic:

-    checked={!column.getIsVisible()}
+    checked={false}
-    onClick={() => column.toggleVisibility(false)}
+    onClick={() => column.toggleVisibility()}
📝 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
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={!column.getIsVisible()}
onClick={() => column.toggleVisibility(false)}
>
<EyeOff />
Hide
</DropdownMenuCheckboxItem>
)}
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsVisible()}
onClick={() => column.toggleVisibility()}
>
<EyeOff />
Visible
</DropdownMenuCheckboxItem>
)}
🤖 Prompt for AI Agents
In src/components/data-table/data-table-column-header.tsx around lines 90 to 99,
the checkbox checked state correctly reflects hidden (checked =
!column.getIsVisible()) but the onClick always calls
column.toggleVisibility(false) so it only ever hides; change the onClick to call
column.toggleVisibility(!column.getIsVisible()) so the action flips visibility
(visible→hide, hidden→show) to match the checkbox state.

failureDate,
}: AffiliatePayoutFailedEmailProps) {
const previewText = `Action required: Your affiliate payout could not be processed`;
const affiliateDashboardUrl = `${env.HOST_NAME}/affiliates`;
Copy link

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 the header file mentioned in the review
fd -name "header.tsx" src/

Repository: webdevcody/agentic-jumpstart

Length of output: 302


🏁 Script executed:

#!/bin/bash
# Search for affiliate-related routes in the codebase
rg -n "affiliate" src/routes -A 2 -B 2 | head -50

Repository: webdevcody/agentic-jumpstart

Length of output: 2715


🏁 Script executed:

#!/bin/bash
# Check the current file to see the context around line 29
cat -n src/components/emails/affiliate-payout-failed-email.tsx | head -40

Repository: webdevcody/agentic-jumpstart

Length of output: 1258


🏁 Script executed:

#!/bin/bash
# Find header-related files
fd header src/components -o fd header src/routes

Repository: webdevcody/agentic-jumpstart

Length of output: 195


🏁 Script executed:

#!/bin/bash
# Search for affiliate dashboard routes/paths
rg "affiliate-dashboard|/affiliates" src/ --type tsx --type ts -B 2 -A 2

Repository: webdevcody/agentic-jumpstart

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Check for route definitions
rg "path.*affiliate|affiliate.*path" src/routes -B 1 -A 1

Repository: webdevcody/agentic-jumpstart

Length of output: 664


🏁 Script executed:

#!/bin/bash
# Search for files with "affiliate" in their name
fd affiliate src/

Repository: webdevcody/agentic-jumpstart

Length of output: 589


🏁 Script executed:

#!/bin/bash
# Search for the header component
find src -name "*header*" -type f

Repository: webdevcody/agentic-jumpstart

Length of output: 580


🏁 Script executed:

#!/bin/bash
# Look for route path definitions - check for file-based routing
ls -la src/routes/ | grep -i affiliate

Repository: webdevcody/agentic-jumpstart

Length of output: 288


🏁 Script executed:

#!/bin/bash
# Search for any references to /affiliates or affiliate-dashboard paths
rg "/affiliates|affiliate-dashboard" src/ -B 2 -A 2

Repository: webdevcody/agentic-jumpstart

Length of output: 22613


Change affiliate dashboard URL to /affiliate-dashboard.

The URL should point to /affiliate-dashboard instead of /affiliates. The /affiliates path is the public affiliate program signup page, while /affiliate-dashboard is the authenticated dashboard where affiliates can view their account status, payouts, and referrals. Since recipients of this email are already active affiliates, they should be directed to their dashboard to check account details, not the public signup page.

🤖 Prompt for AI Agents
In src/components/emails/affiliate-payout-failed-email.tsx around line 29, the
affiliateDashboardUrl currently points to `${env.HOST_NAME}/affiliates`; change
the path to `${env.HOST_NAME}/affiliate-dashboard` so the link directs
recipients to the authenticated affiliate dashboard rather than the public
signup page; ensure the string interpolation and any trailing slash behavior
remain consistent with other URL usages in the codebase.

Comment on lines 60 to 66
// Clear cookies after successful validation
deleteCookie("stripe_connect_state");
deleteCookie("stripe_connect_affiliate_id");
if (onboardingInProgress) {
deleteCookie("affiliate_onboarding");
}

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Cookies cleared before validating authorization code presence.

After CSRF validation succeeds, cookies are cleared (lines 61-65) before checking if code exists. If the request lacks a code, the user cannot retry without restarting the OAuth flow since the state cookie is already deleted.

🔎 Proposed fix - validate code before clearing cookies
     // Clear cookies after successful validation
-    deleteCookie("stripe_connect_state");
-    deleteCookie("stripe_connect_affiliate_id");
-    if (onboardingInProgress) {
-      deleteCookie("affiliate_onboarding");
+    // Validate code presence before clearing cookies
+    if (!state) {
+      return new Response("Missing authorization code", { status: 400 });
     }

+    // Clear cookies only after all validation passes
+    deleteCookie("stripe_connect_state");
+    deleteCookie("stripe_connect_affiliate_id");
+    if (onboardingInProgress) {
+      deleteCookie("affiliate_onboarding");
+    }
+
     try {

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

🤖 Prompt for AI Agents
In src/routes/api/connect/stripe/callback/index.ts around lines 60 to 66,
cookies for stripe_connect_state, stripe_connect_affiliate_id, and
affiliate_onboarding are being deleted immediately after CSRF validation but
before verifying the presence of the authorization code; update the flow so you
first check whether the required code exists (and handle the error/redirect if
missing) and only after confirming the code is present (or after completing any
error-handling that should still allow retries) delete those cookies — move the
deleteCookie calls to after the code-presence check or into the successful-path
branch so a missing code allows the client to retry the OAuth flow with the
preserved state cookie.

Comment on lines +12 to +15
function buildCookie(name: string, value: string, maxAge: number): string {
const secure = env.NODE_ENV === "production" ? "; Secure" : "";
return `${name}=${value}; Path=/; HttpOnly; SameSite=lax; Max-Age=${maxAge}${secure}`;
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Extract duplicated buildCookie helper to a shared utility.

The buildCookie function is duplicated across multiple Stripe Connect route files (src/routes/api/connect/stripe/oauth/index.ts and src/routes/api/connect/stripe/index.ts). Extract this to a shared utility module to reduce duplication.

🔎 Suggested refactor

Create a new utility file src/utils/cookies.ts:

import { env } from "~/utils/env";

export function buildCookie(name: string, value: string, maxAge: number): string {
  const secure = env.NODE_ENV === "production" ? "; Secure" : "";
  return `${name}=${value}; Path=/; HttpOnly; SameSite=lax; Max-Age=${maxAge}${secure}`;
}

Then import and use it in both route files.

🤖 Prompt for AI Agents
In src/routes/api/connect/stripe/oauth/index.ts around lines 12 to 15, the local
buildCookie function is duplicated across Stripe Connect route files; extract it
into a shared utility (e.g., src/utils/cookies.ts) that imports env and exports
buildCookie(name, value, maxAge), then replace the local buildCookie in this
file (and in src/routes/api/connect/stripe/index.ts) with an import from the new
utility to remove duplication.

Comment on lines +93 to +100
const accountLink = await stripe.accountLinks.create({
account: affiliate.stripeConnectAccountId,
refresh_url: `${env.HOST_NAME}/api/connect/stripe/refresh?state=${newState}`,
return_url: `${env.HOST_NAME}/api/connect/stripe/callback?state=${newState}`,
type: "account_onboarding",
});

return Response.redirect(accountLink.url);
Copy link

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 look at the file structure around the Stripe routes
find . -type f -name "*.ts" -path "*stripe*" | head -20

Repository: webdevcody/agentic-jumpstart

Length of output: 398


🏁 Script executed:

# Check how setCookie is used in the codebase with redirects
rg -n "setCookie" --type ts -B2 -A5 | head -100

Repository: webdevcody/agentic-jumpstart

Length of output: 5977


🏁 Script executed:

# Look for the comparison route mentioned in the review
find . -type f -path "*api/connect/stripe/index.ts"

Repository: webdevcody/agentic-jumpstart

Length of output: 112


🏁 Script executed:

# Check if there's a pattern of manually building headers with Set-Cookie
rg -n "Set-Cookie" --type ts -B3 -A3 | head -80

Repository: webdevcody/agentic-jumpstart

Length of output: 1961


🏁 Script executed:

cat -n src/routes/api/connect/stripe/refresh/index.ts

Repository: webdevcody/agentic-jumpstart

Length of output: 4720


🏁 Script executed:

# Check the buildCookie helper function
rg -n "buildCookie" --type ts -B2 -A10 | head -40

Repository: webdevcody/agentic-jumpstart

Length of output: 3275


🏁 Script executed:

# Look for how throw redirect() works vs Response.redirect()
rg -n "throw redirect" --type ts -B2 -A2

Repository: webdevcody/agentic-jumpstart

Length of output: 6594


Cookies will not be sent with Response.redirect() — use throw redirect() or manually set headers.

The setCookie calls (lines 75-90) do not automatically attach cookies to Response.redirect(). TanStack React Start requires either:

  1. throw redirect() (preferred for simple redirects)
  2. Manually build headers with Set-Cookie (as used in /api/connect/stripe/index.ts with the buildCookie helper)

This pattern is enforced throughout the codebase (see /api/login/google/index.ts and /api/logout.ts) with comments noting it avoids "immutable headers errors." The callback route will not receive the required cookies, breaking the OAuth flow.

🤖 Prompt for AI Agents
In src/routes/api/connect/stripe/refresh/index.ts around lines 93 to 100,
Response.redirect(accountLink.url) will not send the cookies set earlier (lines
~75-90), breaking the OAuth flow; replace the plain Response.redirect with
either a throw redirect(accountLink.url) so the framework attaches cookies, or
construct and return a Response with a 302 status and a Location header plus the
Set-Cookie headers (use the same buildCookie helper used in
/api/connect/stripe/index.ts) so the state and CSRF cookies are delivered to the
callback.

Comment on lines +45 to +77
const getAffiliateInfoFn = createServerFn()
.inputValidator(getAffiliateInfoSchema)
.handler(async ({ data }) => {
const { getAffiliateByCode } = await import("~/data-access/affiliates");
const { getProfile } = await import("~/data-access/profiles");
const affiliate = await getAffiliateByCode(data.code);

if (!affiliate || !affiliate.isActive) {
return null;
}

// Get display name if enabled
let displayName = "";
const profile = await getProfile(affiliate.userId);
if (profile?.useDisplayName && profile.displayName) {
displayName = profile.displayName;
}

return {
discountRate: affiliate.discountRate,
commissionRate: affiliate.commissionRate - affiliate.discountRate,
displayName,
};
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing middleware on server function.

getAffiliateInfoFn lacks middleware. Per coding guidelines, TanStack server functions should include middleware. For unauthenticated access, use unauthenticatedMiddleware.

🔎 Proposed fix
+import { unauthenticatedMiddleware } from "~/lib/auth";

-const getAffiliateInfoFn = createServerFn()
+const getAffiliateInfoFn = createServerFn({ method: "GET" })
+  .middleware([unauthenticatedMiddleware])
   .inputValidator(getAffiliateInfoSchema)
   .handler(async ({ data }) => {
📝 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
const getAffiliateInfoFn = createServerFn()
.inputValidator(getAffiliateInfoSchema)
.handler(async ({ data }) => {
const { getAffiliateByCode } = await import("~/data-access/affiliates");
const { getProfile } = await import("~/data-access/profiles");
const affiliate = await getAffiliateByCode(data.code);
if (!affiliate || !affiliate.isActive) {
return null;
}
// Get display name if enabled
let displayName = "";
const profile = await getProfile(affiliate.userId);
if (profile?.useDisplayName && profile.displayName) {
displayName = profile.displayName;
}
return {
discountRate: affiliate.discountRate,
commissionRate: affiliate.commissionRate - affiliate.discountRate,
displayName,
};
});
import { unauthenticatedMiddleware } from "~/lib/auth";
const getAffiliateInfoFn = createServerFn({ method: "GET" })
.middleware([unauthenticatedMiddleware])
.inputValidator(getAffiliateInfoSchema)
.handler(async ({ data }) => {
const { getAffiliateByCode } = await import("~/data-access/affiliates");
const { getProfile } = await import("~/data-access/profiles");
const affiliate = await getAffiliateByCode(data.code);
if (!affiliate || !affiliate.isActive) {
return null;
}
// Get display name if enabled
let displayName = "";
const profile = await getProfile(affiliate.userId);
if (profile?.useDisplayName && profile.displayName) {
displayName = profile.displayName;
}
return {
discountRate: affiliate.discountRate,
commissionRate: affiliate.commissionRate - affiliate.discountRate,
displayName,
};
});
🤖 Prompt for AI Agents
In src/routes/purchase.tsx around lines 45 to 68, the server function
getAffiliateInfoFn is missing required middleware; import the
unauthenticatedMiddleware (e.g. from your server middleware module) and attach
it to the server function chain (call .middleware(unauthenticatedMiddleware) on
the createServerFn() result) before inputValidator/handler so the function
follows the TanStack middleware convention for unauthenticated endpoints.

@amoscicki amoscicki force-pushed the AM04/feat/affiliates branch 2 times, most recently from e2c0b73 to 272e67d Compare December 28, 2025 20:09
Copy link

@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: 12

Caution

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

⚠️ Outside diff range comments (2)
src/routes/admin/affiliates.tsx (2)

71-76: Use config constant for minimum payout validation.

The hardcoded 50 in the validation schema contradicts the configurable minimum payout feature. Consider using AFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUT / 100 or deriving the value dynamically.

🔎 Proposed fix
+import { AFFILIATE_CONFIG } from "~/config";
+
+const MIN_PAYOUT_DOLLARS = AFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUT / 100;
+
 const payoutSchema = z.object({
-  amount: z.number().min(50, "Minimum payout is $50"),
+  amount: z.number().min(MIN_PAYOUT_DOLLARS, `Minimum payout is $${MIN_PAYOUT_DOLLARS}`),
   paymentMethod: z.string().min(1, "Payment method is required"),
   transactionId: z.string().optional(),
   notes: z.string().optional(),
 });

659-662: Hardcoded minimum payout text contradicts configurable setting.

The form description shows "Minimum payout is $50.00" but the minimum payout is now configurable via admin settings. This should reflect the actual configured value.

🔎 Proposed fix
-                    <FormDescription>Minimum payout is $50.00</FormDescription>
+                    <FormDescription>
+                      Minimum payout is ${minimumPayoutState.displayAmount}
+                    </FormDescription>
♻️ Duplicate comments (19)
src/components/ui/button.tsx (1)

42-63: Critical: Ref forwarding removed — breaks CalendarDayButton.

The Button component no longer forwards refs (previously used React.forwardRef). This is a breaking change. In src/components/ui/calendar.tsx (line 194-195), CalendarDayButton passes ref={ref} to Button, which will silently fail since ref is no longer a valid prop.

To fix, restore ref forwarding or use the asChild pattern with a native <button>.

🔎 Proposed fix to restore ref forwarding
-function Button({
+const Button = React.forwardRef<
+  HTMLButtonElement,
+  React.ComponentProps<"button"> &
+    VariantProps<typeof buttonVariants> & {
+      asChild?: boolean
+    }
+>(function Button(
+  {
     className,
     variant = "default",
     size = "default",
     asChild = false,
     ...props
-}: React.ComponentProps<"button"> &
-  VariantProps<typeof buttonVariants> & {
-    asChild?: boolean
-  }) {
+  },
+  ref
+) {
   const Comp = asChild ? Slot : "button"

   return (
     <Comp
+      ref={ref}
       data-slot="button"
       data-variant={variant}
       data-size={size}
       className={cn(buttonVariants({ variant, size, className }))}
       {...props}
     />
   )
-}
+})
src/client.tsx (1)

4-11: Remove NuqsAdapter from client.tsx to prevent double-wrapping (previously flagged).

This wrapping was already identified in a previous review as incorrect. The NuqsAdapter should only wrap the root route (in src/routes/__root.tsx), not the hydration point. Double-wrapping creates duplicate contexts, stale references, and potential hydration mismatches.

Remove the NuqsAdapter import and wrapper from this file, keeping only the wrapping in the root route.

🔎 Proposed fix
 import { StartClient } from "@tanstack/react-start/client";
 import { StrictMode } from "react";
 import { hydrateRoot } from "react-dom/client";
-import { NuqsAdapter } from "nuqs/adapters/react";

 hydrateRoot(
   document,
   <StrictMode>
-    <NuqsAdapter>
-      <StartClient />
-    </NuqsAdapter>
+    <StartClient />
   </StrictMode>
 );
src/lib/format.ts (1)

8-13: Fix default options handling logic.

The current implementation has the issue identified in the previous review: lines 9-11 set defaults using ??, but line 12 spreads ...opts, which can override those defaults with explicit undefined values.

For example:

formatDate(date, { month: undefined })
// Line 9: month is set to "long" via ??
// Line 12: opts spread overwrites with month: undefined
// Result: invalid DateTimeFormat options
🔎 Proposed fix
   try {
     return new Intl.DateTimeFormat("en-US", {
-      month: opts.month ?? "long",
-      day: opts.day ?? "numeric",
-      year: opts.year ?? "numeric",
+      month: "long",
+      day: "numeric",
+      year: "numeric",
       ...opts,
     }).format(new Date(date));
   } catch (_err) {

This way, the defaults are set first, and any values in opts (including explicit undefined) will override them naturally.

src/components/emails/affiliate-payout-failed-email.tsx (1)

29-29: Change affiliate dashboard URL to /affiliate-dashboard.

The URL should point to /affiliate-dashboard instead of /affiliates. The /affiliates path is the public affiliate program signup page, while /affiliate-dashboard is the authenticated dashboard where affiliates can view their account status and payouts.

🔎 Proposed fix
-  const affiliateDashboardUrl = `${env.HOST_NAME}/affiliates`;
+  const affiliateDashboardUrl = `${env.HOST_NAME}/affiliate-dashboard`;
src/routes/api/connect/stripe/oauth/index.ts (1)

12-15: Extract duplicated buildCookie helper to shared utility.

The buildCookie function is duplicated across multiple Stripe Connect route files. This should be extracted to a shared utility module (e.g., src/utils/cookies.ts) to reduce duplication and maintain consistency.

🔎 Suggested refactor

Create src/utils/cookies.ts:

import { env } from "~/utils/env";

export function buildCookie(name: string, value: string, maxAge: number): string {
  const secure = env.NODE_ENV === "production" ? "; Secure" : "";
  return `${name}=${value}; Path=/; HttpOnly; SameSite=lax; Max-Age=${maxAge}${secure}`;
}

Then import in this file:

+import { buildCookie } from "~/utils/cookies";
 import { generateCsrfState } from "~/utils/crypto";

 const MAX_COOKIE_AGE_SECONDS = 60 * 10; // 10 minutes

-function buildCookie(name: string, value: string, maxAge: number): string {
-  const secure = env.NODE_ENV === "production" ? "; Secure" : "";
-  return `${name}=${value}; Path=/; HttpOnly; SameSite=lax; Max-Age=${maxAge}${secure}`;
-}
.env.sample (1)

40-40: Remove duplicate TOKEN_SIGNING_SECRET entry.

Line 40 contains a duplicate TOKEN_SIGNING_SECRET=CHANGE_ME that will override the well-documented entry at lines 16-18 (which includes helpful generation instructions using openssl rand -base64 32). Most .env parsers use the last occurrence, so this duplicate removes the helpful context.

Additionally, the file is missing a trailing blank line, which is flagged by the linter.

🔎 Proposed fix
 OPENAI_API_KEY=
-
-TOKEN_SIGNING_SECRET=CHANGE_ME
+

Keep only the TOKEN_SIGNING_SECRET entry at lines 16-18 and add a trailing newline.

src/routes/api/connect/stripe/refresh/index.ts (1)

75-100: Cookies will not be sent with Response.redirect() — use manual headers.

The setCookie calls (lines 75-90) do not automatically attach cookies to Response.redirect(). TanStack React Start requires manually building headers with Set-Cookie for redirects. This will break the OAuth retry flow as the state cookies won't be delivered.

🔎 Proposed fix using manual headers
+      // Build cookie headers manually for redirect
+      const headers = new Headers();
+      headers.set("Location", accountLink.url);
+      
+      const cookieOptions = `Path=/; HttpOnly; ${env.NODE_ENV === "production" ? "Secure; " : ""}SameSite=Lax; Max-Age=${MAX_COOKIE_AGE_SECONDS}`;
+      headers.append("Set-Cookie", `stripe_connect_state=${newState}; ${cookieOptions}`);
+      headers.append("Set-Cookie", `stripe_connect_affiliate_id=${affiliate.id}; ${cookieOptions}`);

-      return Response.redirect(accountLink.url);
+      return new Response(null, { status: 302, headers });
src/components/data-table/data-table-column-header.tsx (2)

33-33: Replace fragile string-based alignment detection with explicit prop.

The className?.includes("ml-auto") check is fragile and tightly couples to specific Tailwind classes.

🔎 Proposed fix
 interface DataTableColumnHeaderProps<TData, TValue>
   extends React.ComponentProps<typeof DropdownMenuTrigger> {
   column: Column<TData, TValue>;
   label: string;
+  align?: "start" | "end";
 }

 export function DataTableColumnHeader<TData, TValue>({
   column,
   label,
   className,
+  align = "start",
   ...props
 }: DataTableColumnHeaderProps<TData, TValue>) {
-  const isRightAligned = className?.includes("ml-auto");
+  const isRightAligned = align === "end";

90-99: Fix inverted hide/show logic — clicking always hides, never shows.

Line 94 calls toggleVisibility(false), so clicking can only hide columns, never show them. The checked={!column.getIsVisible()} shows a checkmark when hidden, but clicking doesn't toggle visibility properly.

🔎 Proposed fix
 {column.getCanHide() && (
   <DropdownMenuCheckboxItem
     className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
-    checked={!column.getIsVisible()}
-    onClick={() => column.toggleVisibility(false)}
+    checked={column.getIsVisible()}
+    onClick={() => column.toggleVisibility()}
   >
     <EyeOff />
-    Hide
+    Visible
   </DropdownMenuCheckboxItem>
 )}
src/routes/admin/feature-flags.tsx (1)

40-41: Replace the no-op toggle with the proper implementation for VIDEO_SEGMENT_CONTENT_TABS.

The flag toggle returns Promise.resolve(undefined), which prevents any changes from persisting. This was flagged in a previous review—please implement the proper toggle function.

src/lib/parsers.ts (1)

89-107: Missing filterId comparison in filter equality check.

The filterItemSchema includes filterId (line 57), but the eq function doesn't compare it. This could cause incorrect equality results when two filters differ only by filterId.

🔎 Proposed fix
         return (
           filter.id === otherFilter.id &&
           valueEqual &&
           filter.variant === otherFilter.variant &&
-          filter.operator === otherFilter.operator
+          filter.operator === otherFilter.operator &&
+          filter.filterId === otherFilter.filterId
         );
src/hooks/use-data-table.ts (1)

91-92: Remove unused query key variables or implement advanced filtering feature.

filtersKey and joinOperatorKey are defined and stored in table.meta.queryKeys but never connected to actual query state management via useQueryState. Compare with pageKey, perPageKey, and sortKey which are all properly wired.

src/lib/data-table.ts (1)

38-38: Redundant ternary — both branches are identical.

This was flagged in a previous review. The expression isPinned ? "var(--background)" : "var(--background)" always evaluates to the same value.

src/routes/affiliates.tsx (1)

8-8: Remove unused import.

useSuspenseQuery is imported but never used in this file.

🔎 Proposed fix
-import { useSuspenseQuery } from "@tanstack/react-query";
src/routes/purchase.tsx (2)

45-68: Missing middleware on server function.

getAffiliateInfoFn lacks middleware. Per coding guidelines, TanStack server functions should include middleware. For unauthenticated access, use unauthenticatedMiddleware.

🔎 Proposed fix
+import { unauthenticatedMiddleware } from "~/lib/auth";

-const getAffiliateInfoFn = createServerFn()
+const getAffiliateInfoFn = createServerFn({ method: "GET" })
+  .middleware([unauthenticatedMiddleware])
   .inputValidator(getAffiliateInfoSchema)
   .handler(async ({ data }) => {

175-181: Address magic numbers and consider extracting constants.

The percentage calculations use hardcoded 100 throughout the file. As per coding guidelines, magic numbers should be consolidated to the top of the file or in /src/config/index.ts.

🔎 Proposed fix
+// Constants for price/percentage calculations
+const CENTS_PER_DOLLAR = 100;
+const PERCENT_DIVISOR = 100;

 // In checkoutFn:
-const basePriceInCents = currentPriceDollars * 100;
+const basePriceInCents = currentPriceDollars * CENTS_PER_DOLLAR;
-finalPriceInCents = Math.round(basePriceInCents * (1 - affiliateDiscount / 100));
+finalPriceInCents = Math.round(basePriceInCents * (1 - affiliateDiscount / PERCENT_DIVISOR));
-const commissionAmountCents = Math.floor((finalPriceInCents * affiliateCommission) / 100);
+const commissionAmountCents = Math.floor((finalPriceInCents * affiliateCommission) / PERCENT_DIVISOR);

 // In RouteComponent:
-Math.round(pricing.currentPrice * (1 - affiliateInfo.discountRate / 100))
+Math.round(pricing.currentPrice * (1 - affiliateInfo.discountRate / PERCENT_DIVISOR))

Also applies to: 208-208, 274-282

src/data-access/app-settings.ts (2)

252-276: Validation inconsistency between getter and setter.

The getter validates price > 0 (line 257) and falls back to default if invalid, but the setter allows price >= 0 (line 272). If an admin sets the price to 0, the getter will reject it and return the fallback price instead.

🔎 Option 1: Disallow zero pricing (recommended)
 export async function setPricingCurrentPrice(price: number): Promise<void> {
-  if (price < 0) {
-    throw new Error("Price must be >= 0");
+  if (price <= 0) {
+    throw new Error("Price must be > 0");
   }
   await setAppSetting(APP_SETTING_KEYS.PRICING_CURRENT_PRICE, price.toString());
 }

282-306: Same validation inconsistency for original price.

The same getter/setter validation mismatch exists for getPricingOriginalPrice (validates > 0) and setPricingOriginalPrice (allows >= 0).

src/routes/affiliate-dashboard.tsx (1)

993-1001: Magic number for minimum payout display.

The $50.00 minimum payout value is hardcoded. This should reference AFFILIATE_CONFIG.MINIMUM_PAYOUT for consistency and maintainability. As per coding guidelines, never hard code magic numbers into code.

🔎 Proposed fix
-                  <span className="text-sm font-medium">$50.00</span>
+                  <span className="text-sm font-medium">
+                    {new Intl.NumberFormat("en-US", {
+                      style: "currency",
+                      currency: "USD",
+                    }).format(AFFILIATE_CONFIG.MINIMUM_PAYOUT / 100)}
+                  </span>
🧹 Nitpick comments (34)
src/routes/learn/$slug/-components/comment-form.tsx (1)

127-135: Consider extracting the fallback URL to avoid duplication.

The Dicebear URL construction is duplicated in both the src attribute and the onError handler. Extract it to a variable for maintainability.

🔎 Suggested refactor
+              const fallbackAvatarUrl = `https://api.dicebear.com/9.x/initials/svg?seed=${(profile?.useDisplayName === false && profile?.realName) ? profile.realName : (profile?.displayName || "user")}&backgroundColor=6366f1&textColor=ffffff`;
               <img
                 className="max-h-10 w-auto object-cover"
-                src={
-                  profile?.image ??
-                  `https://api.dicebear.com/9.x/initials/svg?seed=${(profile?.useDisplayName === false && profile?.realName) ? profile.realName : (profile?.displayName || "user")}&backgroundColor=6366f1&textColor=ffffff`
-                }
+                src={profile?.image ?? fallbackAvatarUrl}
                 alt="Your avatar"
                 onError={(e) => {
                   e.currentTarget.onerror = null;
-                  e.currentTarget.src = `https://api.dicebear.com/9.x/initials/svg?seed=${(profile?.useDisplayName === false && profile?.realName) ? profile.realName : (profile?.displayName || "user")}&backgroundColor=6366f1&textColor=ffffff`;
+                  e.currentTarget.src = fallbackAvatarUrl;
                 }}
               />
src/routes/admin/-components/admin-nav.tsx (1)

125-130: Consider using a more semantically appropriate icon for Feature Flags.

The Settings icon is retained for the renamed "Feature Flags" item. Consider using a flag-related icon (e.g., Flag from lucide-react) to better represent the functionality.

CLAUDE.md (1)

154-186: Consider documenting the error response format.

The success case is well-documented, but the convention doesn't specify the error response shape. Consider adding guidance for error handling:

🔎 Suggested addition
// For errors, throw or return error responses consistently
export const getItemsFn = createServerFn({ method: "GET" })
  .handler(async () => {
    try {
      const items = await getItems();
      return { success: true, data: items };
    } catch (error) {
      // Document your error handling pattern here
      throw error; // or return { success: false, error: "..." }
    }
  });
src/utils/stripe-status.ts (1)

24-39: The disabled_reason check already handles past_due requirements.

Stripe's disabled_reason enum includes "requirements.past_due" as a value—when past_due items exist, disabled_reason is set to this value. The current check for disabled_reason therefore already catches accounts with past_due fields without needing to check requirements.past_due separately.

However, accounts with requirements.currently_due items (fields due but not yet overdue) won't trigger disabled_reason and will return ONBOARDING status. If you want to surface accounts needing near-term attention before they become restricted, consider adding a check for currently_due items.

src/features/affiliates/README.md (1)

1-1: Consider expanding the README with feature documentation.

This placeholder README could benefit from additional context about the affiliate feature, including:

  • Overview of the affiliate program functionality
  • Key components and flows (Stripe Connect, referral tracking, commission structure)
  • Setup instructions or configuration details
  • Links to relevant documentation

This would help developers understand the feature structure at a glance.

src/routes/profile/$userId.tsx (1)

300-300: Use a more conventional key format for React list items.

The key index+tech creates strings via implicit concatenation (e.g., "0JavaScript"). Consider using a template literal for clarity, or just tech if technologies are unique within a project.

🔎 Suggested alternatives

Option 1: Template literal (clearer intent)

-                                    key={index+tech}
+                                    key={`${index}-${tech}`}

Option 2: Use tech alone if unique per project

-                                    key={index+tech}
+                                    key={tech}

Option 3: Most robust - include project ID

-                                    key={index+tech}
+                                    key={`${project.id}-${tech}`}
src/utils/url-sanitizer.ts (1)

13-25: Consider restricting to HTTPS-only in production environments.

Line 17 allows both http: and https: protocols. While this may be necessary for local development, allowing http: URLs in production HTTPS environments can trigger mixed content warnings in browsers and potentially downgrade security.

💡 Optional enhancement

Add environment-aware protocol validation:

+import { env } from "~/utils/env";
+
 export function sanitizeImageUrl(url: string | null | undefined): string | null {
   if (!url) {
     return null;
   }
 
   try {
     const parsed = new URL(url);
 
-    // Only allow http and https protocols
-    if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
+    // In production, only allow https; in development, allow http too
+    const allowedProtocols = env.NODE_ENV === "production" 
+      ? ["https:"]
+      : ["https:", "http:"];
+    
+    if (!allowedProtocols.includes(parsed.protocol)) {
       return null;
     }
 
     return url;
   } catch {
     // Invalid URL
     return null;
   }
 }
src/routes/api/connect/stripe/index.ts (1)

104-104: Consider using the structured server logger.

The AI summary mentions src/lib/server-logger.ts is available for structured logging. Using it instead of console.error would provide better observability and consistent log formatting across the codebase.

🔎 Proposed fix
+import { createServerLogger } from "~/lib/server-logger";
+
+const logger = createServerLogger("stripe-connect");
+
 // In the catch block:
-      console.error("Stripe Connect account creation error:", error);
+      logger.error("Stripe Connect account creation error", { error });
src/components/blocks/number-input-with-controls.tsx (1)

57-65: Input clearing behavior may be unexpected.

When the user clears the input field, it immediately converts to 0 (line 60). This prevents users from clearing the field before typing a new value. Consider allowing empty string temporarily and only clamping on blur.

🔎 Proposed fix to allow temporary empty state
 const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
   const val = e.target.value;
-  if (val === "" || /^\d+$/.test(val)) {
-    let numVal = val === "" ? 0 : parseInt(val, 10);
-    if (max !== undefined && numVal > max) numVal = max;
-    if (numVal < min) numVal = min;
-    onChange(numVal.toString());
+  if (val === "" || /^\d+$/.test(val)) {
+    if (val === "") {
+      onChange("");
+      return;
+    }
+    let numVal = parseInt(val, 10);
+    if (max !== undefined && numVal > max) numVal = max;
+    if (numVal < min) numVal = min;
+    onChange(numVal.toString());
   }
 };

Then add an onBlur handler to clamp empty values:

const handleBlur = () => {
  if (value === "") {
    onChange(min.toString());
  }
};
src/components/ui/calendar.tsx (1)

208-212: Consider breaking up the long className for maintainability.

The className string on line 209 is very long and difficult to read. Breaking it into logical groups would improve maintainability.

🔎 Proposed refactor
+const dayButtonClassName = cn(
+  // Selection states
+  "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground",
+  "data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground",
+  "data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground",
+  "data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground",
+  // Focus states
+  "group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50",
+  "group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px]",
+  // Layout
+  "flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal",
+  "dark:hover:text-accent-foreground",
+  // Range rounding
+  "data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md",
+  "data-[range-middle=true]:rounded-none",
+  "data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md",
+  "[&>span]:text-xs [&>span]:opacity-70"
+);

// Then use:
-      className={cn(
-        "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
-        defaultClassNames.day,
-        className
-      )}
+      className={cn(dayButtonClassName, defaultClassNames.day, className)}
src/components/data-table/data-table-date-filter.tsx (2)

189-193: Type cast could be cleaner.

The cast e as unknown as React.MouseEvent works but is inelegant. Consider extracting the reset logic to avoid the cast.

🔎 Proposed fix
+  const handleReset = React.useCallback(() => {
+    column.setFilterValue(undefined);
+  }, [column]);
+
   const onReset = React.useCallback(
     (event: React.MouseEvent) => {
       event.stopPropagation();
-      column.setFilterValue(undefined);
+      handleReset();
     },
-    [column],
+    [handleReset],
   );

// Then in onKeyDown:
   onKeyDown={(e) => {
     if (e.key === "Enter" || e.key === " ") {
       e.preventDefault();
-      onReset(e as unknown as React.MouseEvent);
+      e.stopPropagation();
+      handleReset();
     }
   }}

206-227: Inconsistent autoFocus between modes.

The range mode calendar (line 208) has autoFocus while the single mode calendar (line 219) does not. Consider adding autoFocus to single mode for consistent behavior.

🔎 Proposed fix
         ) : (
           <Calendar
+            autoFocus
             captionLayout="dropdown"
             mode="single"
src/lib/server-logger.ts (2)

16-23: Consider externalizing scope configuration.

The scope enablement is hardcoded with inline comments. For production flexibility, consider making this configurable via environment variables.

🔎 Example approach
 export const logScopeConfig: Record<LogScope, boolean> = {
-  default: process.env.NODE_ENV === "development",
-  payments: true, // always log payments
-  auth: true,
-  affiliates: false, // disabled - too noisy
-  "early-access": false,
-  "app-settings": false,
+  default: process.env.LOG_SCOPE_DEFAULT !== "false" && process.env.NODE_ENV === "development",
+  payments: process.env.LOG_SCOPE_PAYMENTS !== "false",
+  auth: process.env.LOG_SCOPE_AUTH !== "false",
+  affiliates: process.env.LOG_SCOPE_AFFILIATES === "true",
+  "early-access": process.env.LOG_SCOPE_EARLY_ACCESS === "true",
+  "app-settings": process.env.LOG_SCOPE_APP_SETTINGS === "true",
 };

67-70: Document the dual-purpose export pattern.

The Object.assign pattern makes logMiddleware both callable and usable directly as middleware, which is clever but non-obvious. A brief JSDoc comment would help consumers understand the API.

🔎 Suggested documentation
+/**
+ * Logging middleware that can be used in two ways:
+ * 1. As a function to create scope-specific middleware: logMiddleware(LOG_SCOPES.PAYMENTS)
+ * 2. Directly as default-scope middleware: .middleware([logMiddleware])
+ */
 export const logMiddleware = Object.assign(
   (scope: LogScope) => createLogMiddlewareForScope(scope),
   defaultMiddleware
 );
src/routes/api/connect/stripe/callback/index.ts (1)

78-85: Consider logging the affiliate mismatch for security auditing.

The affiliate ID mismatch check (line 79) is a security measure, but returning a 403 without logging could make debugging or detecting attack patterns difficult.

🔎 Suggested enhancement
       // Verify affiliate ID matches (extra security)
       if (storedAffiliateId && String(affiliate.id) !== storedAffiliateId) {
+        console.warn("Stripe Connect callback: affiliate ID mismatch", {
+          expected: storedAffiliateId,
+          actual: affiliate.id,
+          userId: user.id,
+        });
         return new Response("Affiliate mismatch", { status: 403 });
       }
src/routes/admin/-affiliates-components/affiliate-details-sheet.tsx (3)

43-68: Consider consolidating formatting helpers with existing utilities.

The AI summary mentions ~/lib/format.ts as a dependency. These local formatting helpers (formatCurrency, formatDate, formatShortDate) may duplicate functionality already available in that module. Consider importing from the shared utility to maintain consistency across the codebase.


154-157: Add error handling for clipboard operations.

The clipboard API can fail (e.g., due to permissions or non-secure contexts). Consider wrapping this in a try-catch to provide graceful degradation.

🔎 Proposed fix
-  const copyToClipboard = (text: string) => {
-    navigator.clipboard.writeText(text);
-    toast.success("Copied to clipboard");
+  const copyToClipboard = async (text: string) => {
+    try {
+      await navigator.clipboard.writeText(text);
+      toast.success("Copied to clipboard");
+    } catch {
+      toast.error("Failed to copy to clipboard");
+    }
   };

179-182: Avoid redundant sanitizeImageUrl call.

sanitizeImageUrl is called twice with the same input—once in the condition and again for the src attribute. Store the result in a variable to improve clarity and avoid redundant computation.

🔎 Proposed fix
+            const sanitizedImageUrl = sanitizeImageUrl(affiliate.userImage);
             {/* Avatar */}
             <div className="relative">
-              {sanitizeImageUrl(affiliate.userImage) ? (
+              {sanitizedImageUrl ? (
                 <img
-                  src={sanitizeImageUrl(affiliate.userImage)!}
+                  src={sanitizedImageUrl}
                   alt={displayName}
src/routes/admin/feature-flags.tsx (1)

180-181: Avoid mutating variable during render.

animationIndex is mutated within the render function via animationIndex++. While this works due to the synchronous nature of the render, it's not idiomatic React and could cause issues if the component re-renders unexpectedly. Consider computing the index directly from the array position.

🔎 Proposed fix
-  // Track global animation index across groups
-  let animationIndex = 0;
-
   return (
     <Page>
       ...
       <div
         className="mt-6 grid gap-6 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-4 animate-in fade-in slide-in-from-bottom-2 duration-500"
         style={{ animationDelay: "0.15s", animationFillMode: "both" }}
       >
-        {filteredAndGroupedFlags.flatMap((groupData) =>
-          groupData.flags.map((flag) => {
-            const currentIndex = animationIndex++;
+        {filteredAndGroupedFlags.flatMap((groupData, groupIndex) => {
+          // Calculate base index for this group
+          const baseIndex = filteredAndGroupedFlags
+            .slice(0, groupIndex)
+            .reduce((acc, g) => acc + g.flags.length, 0);
+          return groupData.flags.map((flag, flagIndex) => {
+            const currentIndex = baseIndex + flagIndex;
             return (
               <FeatureFlagCardWrapper
                 key={flag.key}
                 flag={flag}
                 state={flagStates?.[flag.key]}
                 animationDelay={`${0.2 + currentIndex * 0.05}s`}
                 ...
               />
             );
-          })
+          });
+        }
         )}
       </div>
src/routes/admin/affiliates.tsx (1)

233-239: Consider using the existing useDebouncedCallback hook.

A manual setTimeout-based debounce is implemented here, but the codebase already has a useDebouncedCallback hook (used in use-data-table.ts). Using the existing hook would improve consistency and handle cleanup automatically.

🔎 Proposed fix
+import { useDebouncedCallback } from "~/hooks/use-debounced-callback";

+const SEARCH_DEBOUNCE_MS = 300;

-  const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
-
-  // Debounce search query
-  useEffect(() => {
-    const timer = setTimeout(() => {
-      setDebouncedSearchQuery(searchQuery);
-    }, 300);
-    return () => clearTimeout(timer);
-  }, [searchQuery]);
+  const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
+  const debouncedSetSearch = useDebouncedCallback(setDebouncedSearchQuery, SEARCH_DEBOUNCE_MS);
+
+  useEffect(() => {
+    debouncedSetSearch(searchQuery);
+  }, [searchQuery, debouncedSetSearch]);
src/routes/admin/pricing.tsx (2)

241-247: Extract magic number for example affiliate discount.

The 12 percent example discount is a magic number. Consider extracting it to a constant at the top of the file with a descriptive name to clarify its purpose.

🔎 Proposed fix
+// Example discount percentage used in affiliate preview
+const EXAMPLE_AFFILIATE_DISCOUNT_PERCENT = 12;
+
 ...
 
           {(() => {
-              const exampleAffiliateDiscount = 12;
+              const exampleAffiliateDiscount = EXAMPLE_AFFILIATE_DISCOUNT_PERCENT;
               const affiliatePrice = Math.round(currentPriceNum * (1 - exampleAffiliateDiscount / 100));

174-184: Consider using shadcn Button component for consistency.

The save button uses a custom styled <button> element. For consistency with the rest of the admin UI and better accessibility, consider using the shadcn Button component with appropriate variants.

🔎 Proposed fix
+import { Button } from "~/components/ui/button";
+import { Loader2 } from "lucide-react";

-            <button
+            <Button
+              variant="outline"
+              size="sm"
               onClick={() => updatePromoLabelMutation.mutate(promoLabel)}
               disabled={!hasPromoLabelChanges || updatePromoLabelMutation.isPending}
-              className="h-10 px-3 rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none"
+              className="h-10"
             >
               {updatePromoLabelMutation.isPending ? (
-                <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
+                <Loader2 className="h-4 w-4 animate-spin" />
               ) : (
                 "Save"
               )}
-            </button>
+            </Button>
src/components/data-table/data-table-faceted-filter.tsx (1)

143-177: Consider adding aria-selected to CommandItem for screen reader support.

While the visual check indicator is present, screen readers would benefit from aria-selected={isSelected} on each CommandItem to properly announce the selection state.

🔎 Proposed enhancement
                  <CommandItem
                    key={option.value}
                    onSelect={() => onItemSelect(option, isSelected)}
+                   aria-selected={isSelected}
                  >
src/lib/data-table.ts (1)

22-25: Extract magic number 150 to a named constant.

The TanStack default column size (150) should be defined as a constant at the top of the file or in the config to improve maintainability.

🔎 Proposed fix
+const TANSTACK_DEFAULT_COLUMN_SIZE = 150;
+
 export function getCommonPinningStyles<TData>({
   // ...
   const columnDef = column.columnDef;
-  const hasExplicitSize = columnDef.size !== undefined && columnDef.size !== 150; // 150 is TanStack default
+  const hasExplicitSize = columnDef.size !== undefined && columnDef.size !== TANSTACK_DEFAULT_COLUMN_SIZE;

As per coding guidelines, magic numbers should be consolidated to named constants.

src/fn/app-settings.ts (1)

260-272: Consider adding validation for price relationship.

The updatePricingSettingsFn validates individual prices but doesn't enforce that originalPrice >= currentPrice (assuming original is the "before discount" price). This could be added at the validation layer to prevent invalid pricing configurations.

🔎 Proposed enhancement using Zod refinement
 export const updatePricingSettingsFn = createServerFn({ method: "POST" })
   .middleware([adminMiddleware])
   .inputValidator(
     z.object({
       currentPrice: z.number().int().min(0).optional(),
       originalPrice: z.number().int().min(0).optional(),
       promoLabel: z.string().optional(),
-    })
+    }).refine(
+      (data) => {
+        if (data.currentPrice !== undefined && data.originalPrice !== undefined) {
+          return data.originalPrice >= data.currentPrice;
+        }
+        return true;
+      },
+      { message: "Original price must be greater than or equal to current price" }
+    )
   )
src/routes/admin/-affiliates-components/affiliates-columns.tsx (2)

42-47: Consider moving formatCurrency to a shared utility.

This helper duplicates common currency formatting logic. Based on the AI summary mentioning formatCurrency in src/lib/format.ts, consider importing from there instead of defining locally.

#!/bin/bash
# Check if formatCurrency exists in lib/format.ts
rg -n "formatCurrency" src/lib/format.ts

78-83: Redundant sanitizeImageUrl call — cache the result.

sanitizeImageUrl(affiliate.userImage) is called twice: once for the conditional check (line 78) and again for the src attribute (line 80). Store the result in a variable to avoid redundant computation.

🔎 Proposed fix
       cell: ({ row }) => {
         const affiliate = row.original;
         const displayName =
           affiliate.userName || affiliate.userEmail?.split("@")[0] || "Unknown";
         const initial = displayName.charAt(0).toUpperCase();
+        const sanitizedImage = sanitizeImageUrl(affiliate.userImage);

         return (
           <div className="flex items-center gap-3 min-w-0 py-1">
             {/* Avatar with status indicator */}
             <div className="relative shrink-0">
-              {sanitizeImageUrl(affiliate.userImage) ? (
+              {sanitizedImage ? (
                 <img
-                  src={sanitizeImageUrl(affiliate.userImage)!}
+                  src={sanitizedImage}
                   alt={displayName}
src/components/data-table/data-table-skeleton.tsx (1)

56-91: Consider extracting duplicated cell style logic.

The same style object structure ({ width: cozyCellWidths[j], minWidth: shrinkZero ? ... }) is used in both TableHead (lines 63-66) and TableCell (lines 80-83). Consider extracting to a helper function.

🔎 Proposed refactor
+ const getCellStyle = (index: number) => ({
+   width: cozyCellWidths[index],
+   minWidth: shrinkZero ? cozyCellWidths[index] : "auto",
+ });

  <TableHead
    key={j}
-   style={{
-     width: cozyCellWidths[j],
-     minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
-   }}
+   style={getCellStyle(j)}
  >
src/routes/affiliate-onboarding.tsx (1)

243-248: Cookie settings lack security attributes.

The cookies for affiliate onboarding lack SameSite and Secure attributes. While these are short-lived (1 hour), adding security attributes is a best practice.

🔎 Proposed fix
  if (selectedMethod === "stripe-express") {
-   document.cookie = `affiliate_onboarding=stripe-express; path=/; max-age=3600`;
+   document.cookie = `affiliate_onboarding=stripe-express; path=/; max-age=3600; SameSite=Lax`;
    window.location.href = "/api/connect/stripe";
  } else if (selectedMethod === "stripe-oauth") {
-   document.cookie = `affiliate_onboarding=stripe-oauth; path=/; max-age=3600`;
+   document.cookie = `affiliate_onboarding=stripe-oauth; path=/; max-age=3600; SameSite=Lax`;
    window.location.href = "/api/connect/stripe/oauth";
src/routes/api/stripe/webhook.ts (1)

267-293: Consider using explicit case statements for transfer events.

The transfer.paid and transfer.failed events are handled in the default case with string comparisons. While the comment explains this is due to type definitions, this could cause issues if Stripe adds these to their types in the future.

🔎 Alternative approach

Consider casting the event type explicitly at the switch level or using a type guard to handle these known event types more cleanly:

// At the top of the file
type ExtendedStripeEventType = Stripe.Event["type"] | "transfer.paid" | "transfer.failed";

// In the switch
switch (event.type as ExtendedStripeEventType) {
  case "transfer.paid": {
    // ...
  }
  case "transfer.failed": {
    // ...
  }
}
src/routes/purchase.tsx (1)

116-141: Consider extracting affiliate lookup logic to reduce handler complexity.

The affiliate lookup and profile fetching in checkoutFn duplicates logic from getAffiliateInfoFn. Consider reusing the existing function or extracting to a shared use-case.

src/routes/affiliate-dashboard.tsx (2)

221-226: Non-null assertion on loader data may mask type issues.

Line 223 uses loaderData.dashboard! with a non-null assertion. While the comment explains the beforeLoad guard ensures this, TypeScript loses the safety guarantee. The fallback defaults on lines 224-226 are good defensive patterns.

Consider optional chaining with throw for clarity
-  const dashboard = loaderData.dashboard!;
+  const dashboard = loaderData.dashboard;
+  if (!dashboard) {
+    throw new Error("Dashboard data unavailable after guard");
+  }

502-515: Slider commits changes immediately on onValueCommit.

The implementation correctly separates local state updates (onValueChange) from server mutations (onValueCommit), providing a smooth UX. However, there's no optimistic update rollback if the mutation fails—the slider stays at the new position even on error.

Consider rolling back on mutation error
  const updateDiscountRateMutation = useMutation({
    mutationFn: updateAffiliateDiscountRateFn,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["affiliate", "dashboard"] });
      toast.success("Discount Split Updated", {
        description: "Your commission split has been updated.",
      });
    },
    onError: (error) => {
+     // Rollback to server value on error
+     setLocalDiscountRate(dashboard.affiliate.discountRate);
      toast.error("Update Failed", {
        description: error.message || "Failed to update discount split.",
      });
    },
  });
src/data-access/affiliates.ts (1)

1-1063: File exceeds 1000 line limit.

At ~1063 lines, this file exceeds the coding guideline limit of 1000 lines. Consider splitting into smaller modules for maintainability:

  • Core affiliate CRUD operations
  • Stripe Connect operations
  • Payout operations
  • Transaction-aware (Tx) variants
Suggested file structure
src/data-access/
├── affiliates/
│   ├── index.ts          # Re-exports all
│   ├── core.ts           # CRUD: createAffiliate, getAffiliateById, etc.
│   ├── stripe.ts         # Stripe: updateAffiliateStripeAccount, etc.
│   ├── payouts.ts        # Payouts: createAffiliatePayout, completePendingPayout
│   └── transactions.ts   # Tx variants: getAffiliateByCodeTx, etc.

Comment on lines +452 to +460
```
User clicks "Connect" → /api/connect/stripe (creates account, generates link)
User completes Stripe onboarding (hosted by Stripe)
Stripe redirects → /api/connect/stripe/callback (updates status)
User returns to affiliate dashboard with connected account
```
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add language specifier to fenced code block.

Per markdownlint, fenced code blocks should have a language specified. Since this represents a flow diagram, consider using text or plaintext.

🔎 Proposed fix
-```
+```text
 User clicks "Connect" → /api/connect/stripe (creates account, generates link)
                       ↓
 User completes Stripe onboarding (hosted by Stripe)
                       ↓
 Stripe redirects → /api/connect/stripe/callback (updates status)
                       ↓
 User returns to affiliate dashboard with connected account
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

452-452: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In docs/features/affiliates/readme.md around lines 452-460, the fenced code
block lacks a language specifier; update the opening fence from ``` to ```text
(or ```plaintext) to satisfy markdownlint for language-tagged fenced code
blocks, keeping the block content and indentation unchanged.

Comment on lines +532 to +539
```
Admin Dashboard → Select Affiliate → "Process Automatic Payout"
```

**Batch Processing:**
```
Admin Dashboard → "Process All Automatic Payouts" → Review Results
```
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add language specifier to fenced code blocks.

The code blocks at lines 532-534 and 537-539 are missing language specifiers. Consider using text for these admin dashboard navigation paths.

🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

532-532: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


537-537: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In docs/features/affiliates/readme.md around lines 532 to 539, the two fenced
code blocks containing Admin Dashboard navigation paths are missing language
specifiers; update each triple-backtick fence to include a language (use "text")
so they read ```text before the content and close with ``` after, keeping the
same block content and formatting.

Comment on lines +1 to +2
CREATE TYPE "public"."affiliate_payment_method_enum" AS ENUM('link', 'stripe');--> statement-breakpoint
CREATE TYPE "public"."stripe_account_status_enum" AS ENUM('not_started', 'onboarding', 'active', 'restricted');--> statement-breakpoint
Copy link

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:

# Search for usage of 'pending' status in TypeScript/JavaScript files
rg -n "'pending'|\"pending\"" -t ts -t js --context=2 | grep -i "stripe\|account" | head -50

# Search for stripeAccountStatus usage and definitions
rg -n "stripeAccountStatus" -t ts -t js --context=3 | head -80

# Check for any schema/types definitions for stripe account status
rg -n "stripe_account_status_enum\|stripeAccountStatus\|account.*status" -t ts -t js -t sql --context=2 | head -100

# Look for PR objectives or documentation mentioning the lifecycle
rg -n "not_started.*onboarding.*pending.*active" --context=2 | head -30

# Check migration file for all enum-related changes
git ls-files "*.sql" | xargs rg -n "stripe_account_status_enum" --context=5

Repository: webdevcody/agentic-jumpstart

Length of output: 8845


Add pending status to stripe_account_status_enum.

The documentation lists pending as a valid account status, but the enum definition only includes ('not_started', 'onboarding', 'active', 'restricted'). The pending state should be added to align with the documented account lifecycle.

🤖 Prompt for AI Agents
In drizzle/0052_funny_hardball.sql around lines 1 to 2, the
stripe_account_status_enum is missing the documented 'pending' value; update the
CREATE TYPE statement for "public"."stripe_account_status_enum" to include
'pending' among the enum members (place it in the appropriate order within the
list) so the enum becomes ('not_started', 'onboarding', 'pending', 'active',
'restricted').

Comment on lines +15 to +20
interface AffiliatePayoutSuccessEmailProps {
affiliateName: string;
payoutAmount: string;
payoutDate: string;
stripeTransferId: string;
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Remove duplicate interface definition.

This interface is already defined in src/utils/email.ts (lines 298-303 per the code snippets). Import it from there to avoid duplication and potential drift.

🔎 Proposed fix
+import type { AffiliatePayoutSuccessEmailProps } from "~/utils/email";
+
-interface AffiliatePayoutSuccessEmailProps {
-  affiliateName: string;
-  payoutAmount: string;
-  payoutDate: string;
-  stripeTransferId: string;
-}
📝 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
interface AffiliatePayoutSuccessEmailProps {
affiliateName: string;
payoutAmount: string;
payoutDate: string;
stripeTransferId: string;
}
import type { AffiliatePayoutSuccessEmailProps } from "~/utils/email";
🤖 Prompt for AI Agents
In src/components/emails/affiliate-payout-success-email.tsx around lines 15 to
20, there is a duplicate definition of AffiliatePayoutSuccessEmailProps that
already exists in src/utils/email.ts (lines ~298-303); remove this local
interface and instead import the AffiliatePayoutSuccessEmailProps type from
src/utils/email.ts, then update the component props type annotation to use the
imported type so the file reuses the shared definition and avoids drift.

Comment on lines +157 to +161
const detailRow = {
display: "flex",
justifyContent: "space-between",
marginBottom: "12px",
};
Copy link

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

🌐 Web query:

react-email flexbox outlook compatibility email clients

💡 Result:

Short answer: don’t rely on CSS flexbox for Outlook. Outlook for Windows (Word-based engines) and many webmail/old clients have poor or no flexbox support, so use table-based layouts or the compatible components/outputs that handle fallbacks (or add MSO conditional HTML) when sending emails. [1][2][3]

What to do with react-email

  • React Email’s components (Section / Row / Column /Container, etc.) are built and inlined for email clients and are tested for Outlook — use those rather than hand-rolled display:flex layouts. They emit email-friendly HTML/CSS and handle many compatibility concerns. [4][5]

Practical tips

  • Build columns/rows with table-friendly components or explicit /
    fallbacks for Outlook. [2][3]
  • Use MSO conditional comments/VML for Outlook-specific fixes (backgrounds, sizing) where needed. [2]
  • Inline all styles and test in real clients (Litmus / Email on Acid / device clients) — Outlook variants are the most likely to break flexbox behavior. [2][3]
  • Sources

    1. Campaign Monitor — email client CSS support (flexbox notes). [turn0search4]
    2. Email-Dev / Email compatibility guide (flexbox unsupported; table fallbacks recommended). [turn0search3]
    3. MailLinter compatibility notes (flexbox high-impact issue; use tables). [turn0search2]
    4. React Email docs — Section/Row/Column/Container components and client testing notes. [turn1search4][turn1search9]
    5. React Email components (Column/Row docs). [turn1search5][turn1search7]

    Use React Email's Row/Column components instead of inline flexbox styles.

    display: "flex" and justifyContent: "space-between" on detailRow won't work reliably in Outlook and many older email clients. Instead, use React Email's built-in Row and Column components (or Section) which are specifically designed for email compatibility and handle these layout concerns across all clients.

    🤖 Prompt for AI Agents
    In src/components/emails/affiliate-payout-success-email.tsx around lines
    157-161, the detailRow object uses display: "flex" and justifyContent:
    "space-between", which is unreliable for many email clients; replace that inline
    flex layout with React Email's Row and Column (or Section) components: remove
    the detailRow flex style, import Row and Column, wrap each “left/right” piece as
    two Columns inside a Row, control spacing with Column widths, padding/margin
    props and textAlign on the Columns, and adjust any existing styles (font, color,
    spacing) to apply to the Column content so the layout renders correctly across
    Outlook and legacy clients.
    

Comment on lines +314 to +341
export const adminGetAffiliatePayoutsFn = createServerFn({ method: "GET" })
.middleware([adminMiddleware])
.inputValidator(getAffiliatePayoutsSchema)
.handler(async ({ data }) => {
const result = await getAffiliatePayouts(data.affiliateId, {
limit: data.limit,
offset: data.offset,
});
return { success: true, data: result };
});

// Admin function to get affiliate referral/conversion history
const getAffiliateReferralsSchema = z.object({
affiliateId: z.number(),
limit: z.number().optional().default(10),
offset: z.number().optional().default(0),
});

export const adminGetAffiliateReferralsFn = createServerFn({ method: "GET" })
.middleware([adminMiddleware])
.inputValidator(getAffiliateReferralsSchema)
.handler(async ({ data }) => {
const result = await getAffiliateReferrals(data.affiliateId, {
limit: data.limit,
offset: data.offset,
});
return { success: true, data: result };
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Admin functions for payouts/referrals call data-access directly.

adminGetAffiliatePayoutsFn and adminGetAffiliateReferralsFn directly call data-access functions (getAffiliatePayouts, getAffiliateReferrals). These should go through use-case functions per the layered architecture guidelines.

🔎 Create admin use-cases
// In use-cases/affiliates.ts
export async function adminGetAffiliatePayoutsUseCase(params: {
  affiliateId: number;
  limit?: number;
  offset?: number;
}) {
  // Optionally verify affiliate exists
  const affiliate = await getAffiliateById(params.affiliateId);
  if (!affiliate) {
    throw new ApplicationError("Affiliate not found", "AFFILIATE_NOT_FOUND");
  }
  return getAffiliatePayouts(params.affiliateId, {
    limit: params.limit,
    offset: params.offset,
  });
}
🤖 Prompt for AI Agents
In src/fn/affiliates.ts around lines 314-341, the admin handlers call
data-access functions directly; create and call admin-level use-case functions
instead. Add adminGetAffiliatePayoutsUseCase and
adminGetAffiliateReferralsUseCase in use-cases/affiliates.ts that accept {
affiliateId, limit?, offset? }, optionally verify the affiliate exists (use
getAffiliateById and throw an ApplicationError with a specific code if not
found), and delegate to getAffiliatePayouts/getAffiliateReferrals; then update
the two handlers to import and call these use-case functions (passing data.limit
and data.offset) rather than calling data-access directly, preserving the
returned shape and error propagation.

export const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: "2025-08-27.basil",
});
export const stripe = new Stripe(env.STRIPE_SECRET_KEY);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Stripe Node.js library apiVersion parameter best practices 2024 2025

💡 Result:

Short best-practices for using stripe-node's apiVersion (2024–2025):

  • Explicitly set apiVersion when creating the client (new Stripe(key, { apiVersion: 'YYYY-MM-DD' })) so your code’s expected object shapes don’t depend on your account default. [1][2]
  • Use per-request apiVersion overrides only for testing or feature-preview calls (and include preview/feature flags in the header string, e.g. "2025-03-31; feature_beta=v1"). [0][1]
  • Treat monthly API releases as backward-compatible within the same major release, but expect breaking changes at the twice‑a‑year major releases — plan upgrades and tests accordingly. [1][4]
  • Pin your SDK/library version in package.json and run automated tests before upgrading the SDK or changing apiVersion; the SDK’s types/docs reflect the SDK-pinned API. [0][3]
  • When changing API version, follow Stripe’s webhook upgrade procedure: create a second (disabled) webhook endpoint with the new api_version, test by receiving both versions, then switch once code is ready. Webhook payloads are versioned independently. [2]
  • For Connect/platform usage, explicitly set apiVersion (or Stripe-Version header) for consistency when making requests on behalf of connected accounts. [1][4]
  • For private/public preview features use the SDK preview/alpha channels or include preview tokens in the apiVersion string and test carefully. [0][4]

Key docs/readme:

  • stripe-node README (config options, per-request apiVersion/feature flags). [0]
  • Stripe API upgrades / versioning guidance. [1]
  • Webhook versioning guide. [2]
  • Stripe SDK/versioning policy. [4]

Sources: [0] stripe-node README/GitHub; [1] Stripe — API upgrades; [2] Stripe — Handle webhook versioning; [3] stripe-node migration/type notes; [4] Stripe — versioning & support policy.


🌐 Web query:

Stripe API versioning automatic upgrade breaking changes documentation

💡 Result:

Summary — how Stripe handles API-version changes and breaking changes

  • Release cadence: monthly API releases are intended to be non‑breaking; twice‑a‑year “major” releases introduce breaking changes. [1][2]
  • Automatic/pinned version behavior: an account is pinned to an API version when it first makes a request; requests that omit a Stripe‑Version header use the account’s default version. You can override per request with the Stripe‑Version header. [3][1]
  • Upgrading: test a newer version by setting Stripe‑Version or using Workbench; when ready, upgrade the account default in Workbench. After upgrading you have a 72‑hour roll‑back window. [3]
  • Webhooks & events: webhook payload shapes are determined by the account default API version at event creation — follow Stripe’s webhook‑versioning guide and create a separate (disabled) endpoint to safely test upgrades. [4]
  • Changelogs & previews: use Stripe’s API changelog and developer notices (and subscribe to the Developer Digest). Stripe documents breaking changes in the changelog and labels releases (example blog explaining the new release process). [2][5]

Key docs

  • Versioning / API reference. [1]
  • Versioning & support policy / release cadence. [2]
  • API upgrades (upgrade steps, rollback). [3]
  • Handle webhook versioning. [4]
  • Blog: new API release process (explanation). [5]

If you want, I can: 1) fetch the current account default API version from your account (instructions), or 2) list recent breaking changes from the changelog (with dates) — which would you like?


🌐 Web query:

Stripe API version changelog 2024-12-18 acacia latest stable version

💡 Result:

Summary — 2024-12-18.acacia changelog (Acacia release, 2024-12-18)

  • Tax: Tax IDs supported in 19 more countries; Tax Registrations supported in 21 more countries; added disabled reason to invoices/subscriptions/schedules. [1]
  • Billing: Billing credits reinstated when an invoice is voided; can adjust free trial days on a subscription at any time. [1]
  • Payments / card networks: Charges include card-network transaction identifier; declines include network advice/error codes; API support for responding to a Visa compliance dispute. [1]
  • Payouts: SDK support for trace IDs; new balance transaction types to support minimum balance. [1]
  • Issuing: Issuing authorizations include merchant tax ID; create authorizations when Stripe is unavailable. [1]
  • Payment methods / Checkout: Added beneficiary/funding details for various methods; SEPA/BACS mandate reference prefix support; Checkout and other smaller enhancements. [1]

Notes

  • 2024-12-18.acacia is the monthly Acacia release dated Dec 18, 2024 (part of the Acacia major release series). [1]
  • Stripe now uses the date+name versioning cadence (monthly updates plus twice-yearly major releases); later major releases (e.g., Basil, Clover) exist after Acacia, so 2024-12-18.acacia is not the newest overall Stripe API version as of today. See Stripe versioning docs. [2][3]

Sources
[1] Stripe changelog — Acacia (Dec 18, 2024).
[2] Stripe docs — Acacia overview / 2024-09-30.acacia notes.
[3] Stripe versioning and support policy.


🌐 Web query:

Stripe API latest version December 2025

💡 Result:

The current Stripe API version (stable) is 2025-12-15.clover (released December 15, 2025). [1][2]

Sources:
[1] Stripe API Versioning docs.
[2] Stripe changelog (2025-12-15.clover).


Restore explicit API version pinning for Stripe client.

Removing the apiVersion parameter means the Stripe client will use your account's default API version, which Stripe can upgrade automatically. This introduces the risk of breaking changes in production without warning—webhook payload structures, API response formats, and behavior may change unexpectedly.

Stripe's best practice is to explicitly pin the API version in your code and upgrade it deliberately after testing.

-export const stripe = new Stripe(env.STRIPE_SECRET_KEY);
+export const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
+  apiVersion: "2025-12-15.clover",
+});
🤖 Prompt for AI Agents
In src/lib/stripe.ts around line 4, the Stripe client is instantiated without an
explicit apiVersion which allows Stripe to use the account default and can cause
unexpected breaking changes; restore explicit API version pinning by re-adding
the apiVersion option to the Stripe constructor (preferably reading the version
from an env variable like env.STRIPE_API_VERSION) so the client is created as
new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: env.STRIPE_API_VERSION }) and
ensure the env var is provided and documented for controlled upgrades.

Comment on lines +172 to +177
useEffect(() => {
if (search.stripeComplete && search.method) {
setSelectedMethod(search.method);
setCurrentStep("terms");
}
}, [search.stripeComplete, search.method]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Step navigation logic appears incorrect after Stripe callback.

When search.stripeComplete is true, the useEffect sets currentStep to "terms", but the user has already accepted terms before being redirected to Stripe. This should likely navigate to "complete" or "payment-method" depending on the flow.

🔎 Proposed fix
  useEffect(() => {
    if (search.stripeComplete && search.method) {
      setSelectedMethod(search.method);
-     setCurrentStep("terms");
+     setCurrentStep("complete");
    }
  }, [search.stripeComplete, search.method]);
📝 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
useEffect(() => {
if (search.stripeComplete && search.method) {
setSelectedMethod(search.method);
setCurrentStep("terms");
}
}, [search.stripeComplete, search.method]);
useEffect(() => {
if (search.stripeComplete && search.method) {
setSelectedMethod(search.method);
setCurrentStep("complete");
}
}, [search.stripeComplete, search.method]);
🤖 Prompt for AI Agents
In src/routes/affiliate-onboarding.tsx around lines 172–177, the useEffect
unconditionally sets currentStep to "terms" after the Stripe callback, which is
wrong because the user already accepted terms before redirect; change the effect
to inspect state and route appropriately: when search.stripeComplete is true, if
the user had already accepted terms (e.g., currentStep === "terms" prior to
redirect or selectedMethod is set) set currentStep to "complete"; otherwise
navigate to "payment-method" (or the correct next step for the flow). Also
include any necessary dependencies (currentStep and/or selectedMethod) in the
effect dependency array so the decision uses up-to-date state.

Comment on lines +8 to +18
export const logger = {
info: (message: string, context?: LogContext) => {
console.log(
JSON.stringify({
level: "info",
timestamp: new Date().toISOString(),
message,
...context,
})
);
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Context spread can overwrite critical log fields.

The ...context spread at lines 15, 25, and 35 allows user-provided context to overwrite the built-in level, timestamp, or message fields. If a caller passes logger.info("Test", { level: "debug", timestamp: "wrong" }), the log output will have incorrect metadata, breaking log parsing and filtering in production monitoring systems.

🔎 Proposed fix

Nest context under a dedicated key to prevent field collisions:

 export const logger = {
   info: (message: string, context?: LogContext) => {
     console.log(
       JSON.stringify({
         level: "info",
         timestamp: new Date().toISOString(),
         message,
-        ...context,
+        ...(context && { context }),
       })
     );
   },
   warn: (message: string, context?: LogContext) => {
     console.warn(
       JSON.stringify({
         level: "warn",
         timestamp: new Date().toISOString(),
         message,
-        ...context,
+        ...(context && { context }),
       })
     );
   },
   error: (message: string, context?: LogContext) => {
     console.error(
       JSON.stringify({
         level: "error",
         timestamp: new Date().toISOString(),
         message,
-        ...context,
+        ...(context && { context }),
       })
     );
   },
 };

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

🤖 Prompt for AI Agents
In src/utils/logger.ts around lines 8 to 18, the current implementation spreads
user-provided context into the top-level log object which allows callers to
overwrite built-in fields (level, timestamp, message). Change the logging shape
to keep built-in fields immutable and place any user context under a dedicated
key (e.g., "meta" or "context") instead of using ...context; update all logger
methods (info/warn/error) to serialize { level, timestamp, message, context:
context ?? undefined } and adjust the LogContext type if necessary so consumers
cannot accidentally overwrite core fields.

Copy link

@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

🧹 Nitpick comments (1)
.env.sample (1)

12-18: Add missing trailing newline and organize Stripe keys alphabetically.

The file lacks a blank line at the end (dotenv convention), and the Stripe-related environment variables are out of alphabetical order. While this doesn't affect runtime behavior, it improves consistency and maintainability.

🔎 Proposed fix
-STRIPE_SECRET_KEY=your_stripe_secret_key
-STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
-STRIPE_PRICE_ID=your_stripe_price_id # likely no longer needed as we use dynamic payment intent
-STRIPE_DISCOUNT_COUPON_ID=your_stripe_discount_coupon_id
-STRIPE_CLIENT_ID=your_oauth2_id_for_connect
+STRIPE_CLIENT_ID=your_oauth2_id_for_connect
+STRIPE_DISCOUNT_COUPON_ID=your_stripe_discount_coupon_id
+STRIPE_PRICE_ID=your_stripe_price_id # likely no longer needed as we use dynamic payment intent
+STRIPE_SECRET_KEY=your_stripe_secret_key
+STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret

And add a blank line at the end of the file after line 38.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 272e67d and ee42902.

📒 Files selected for processing (1)
  • .env.sample
🧰 Additional context used
🪛 dotenv-linter (4.0.0)
.env.sample

[warning] 12-12: [UnorderedKey] The STRIPE_PRICE_ID key should go before the STRIPE_SECRET_KEY key

(UnorderedKey)


[warning] 12-12: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)


[warning] 13-13: [UnorderedKey] The STRIPE_DISCOUNT_COUPON_ID key should go before the STRIPE_PRICE_ID key

(UnorderedKey)


[warning] 14-14: [UnorderedKey] The STRIPE_CLIENT_ID key should go before the STRIPE_DISCOUNT_COUPON_ID key

(UnorderedKey)


[warning] 38-38: [EndingBlankLine] No blank line at the end of the file

(EndingBlankLine)

⏰ 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). (1)
  • GitHub Check: Run Playwright Tests
🔇 Additional comments (1)
.env.sample (1)

14-14: Good: New environment variables align with Stripe Connect integration.

The addition of STRIPE_CLIENT_ID and TOKEN_SIGNING_SECRET with clear generation instructions supports the OAuth onboarding and token-signing flows described in the PR objectives. The comment on STRIPE_PRICE_ID correctly notes that dynamic payment intents may supersede its use.

Verify that STRIPE_PRICE_ID is either still required by the codebase or can be safely removed in a future cleanup.

Also applies to: 16-18

Copy link

@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 (1)
src/routes/purchase.tsx (1)

54-77: Missing middleware on server function.

getAffiliateInfoFn lacks middleware. Per coding guidelines, TanStack server functions should include middleware. For unauthenticated access, use .middleware([unauthenticatedMiddleware]).

🔎 Proposed fix
+import { unauthenticatedMiddleware } from "~/lib/auth";

-const getAffiliateInfoFn = createServerFn()
+const getAffiliateInfoFn = createServerFn({ method: "GET" })
+  .middleware([unauthenticatedMiddleware])
   .inputValidator(getAffiliateInfoSchema)
   .handler(async ({ data }) => {

Based on learnings and past review comments.

🧹 Nitpick comments (5)
src/routes/api/connect/stripe/callback/index.ts (2)

42-58: Inconsistent indentation in handler body.

The handler function body (lines 43-129) should be indented one additional level to align with the GET: handler definition. This affects readability and may cause issues with formatters.

🔎 Proposed fix for indentation
       GET: async ({ request }) => {
-    const url = new URL(request.url);
-    const state = url.searchParams.get("state");
-    const storedState = getCookie("stripe_connect_state") ?? null;
-    const storedAffiliateId = getCookie("stripe_connect_affiliate_id") ?? null;
-    const onboardingInProgress = getCookie("affiliate_onboarding") ?? null;
-
-    // Validate CSRF state token using timing-safe comparison
-    // Clear cookies on validation failure to prevent reuse
-    if (!timingSafeStringEqual(state, storedState)) {
-      deleteCookie("stripe_connect_state");
-      deleteCookie("stripe_connect_affiliate_id");
-      if (onboardingInProgress) {
-        deleteCookie("affiliate_onboarding");
-      }
-      return new Response("Invalid state parameter", { status: 400 });
-    }
+        const url = new URL(request.url);
+        const state = url.searchParams.get("state");
+        const storedState = getCookie("stripe_connect_state") ?? null;
+        const storedAffiliateId = getCookie("stripe_connect_affiliate_id") ?? null;
+        const onboardingInProgress = getCookie("affiliate_onboarding") ?? null;
+
+        // Validate CSRF state token using timing-safe comparison
+        // Clear cookies on validation failure to prevent reuse
+        if (!timingSafeStringEqual(state, storedState)) {
+          deleteCookie("stripe_connect_state");
+          deleteCookie("stripe_connect_affiliate_id");
+          if (onboardingInProgress) {
+            deleteCookie("affiliate_onboarding");
+          }
+          return new Response("Invalid state parameter", { status: 400 });
+        }

123-128: Consider using the project's structured logger instead of console.error.

Error logging uses the project's logger (src/utils/logger.ts) to record failures. For consistency with other parts of the codebase, consider importing and using the structured logger.

🔎 Proposed fix

Add to imports:

import { logger } from "~/utils/logger";

Then update error handling:

     } catch (error) {
-      console.error("Stripe Connect callback error:", error);
+      logger.error("Stripe Connect callback error:", error);
       // Don't clear cookies on transient errors - allow retry
       return new Response("Failed to complete Stripe Connect onboarding", {
         status: 500,
       });
     }
src/routes/purchase.tsx (1)

312-314: Replace magic numbers with PERCENTAGE_DIVISOR constant.

Lines 313 and 476 use magic number 100 for percentage calculations. Per coding guidelines, use the PERCENTAGE_DIVISOR constant defined at the top of the file.

🔎 Proposed fix
   const discountPercentage = pricing.originalPrice > pricing.currentPrice
-    ? Math.round(((pricing.originalPrice - pricing.currentPrice) / pricing.originalPrice) * 100)
+    ? Math.round(((pricing.originalPrice - pricing.currentPrice) / pricing.originalPrice) * PERCENTAGE_DIVISOR)
     : 0;
                             <div className="text-xs text-center text-muted-foreground bg-muted/50 rounded-lg py-2 px-3">
-                              Total savings: {Math.round(((pricing.originalPrice - discountedPrice) / pricing.originalPrice) * 100)}% off original
+                              Total savings: {Math.round(((pricing.originalPrice - discountedPrice) / pricing.originalPrice) * PERCENTAGE_DIVISOR)}% off original
                             </div>

As per coding guidelines.

Also applies to: 474-477

src/routes/affiliate-dashboard.tsx (2)

527-544: Extract magic numbers to constants.

Lines 532 and 540 use magic number 100 for percentage-to-decimal conversion. Per coding guidelines, extract this to a constant at the top of the file (e.g., PERCENTAGE_DIVISOR = 100).

🔎 Proposed fix

Add near the top of the file (after imports):

+/** Divisor for converting percentage values (e.g., 30% -> 0.30) */
+const PERCENTAGE_DIVISOR = 100;

Then update the calculations:

                   <p className="text-2xl font-bold text-green-600 dark:text-green-400">
-                    ${((pricingSettings.currentPrice * localDiscountRate) / 100).toFixed(0)}
+                    ${((pricingSettings.currentPrice * localDiscountRate) / PERCENTAGE_DIVISOR).toFixed(0)}
                   </p>
                   <p className="text-2xl font-bold text-theme-600 dark:text-theme-400">
-                    ${((pricingSettings.currentPrice * (dashboard.affiliate.commissionRate - localDiscountRate)) / 100).toFixed(0)}
+                    ${((pricingSettings.currentPrice * (dashboard.affiliate.commissionRate - localDiscountRate)) / PERCENTAGE_DIVISOR).toFixed(0)}
                   </p>

As per coding guidelines.


993-1006: Extract cents-to-dollars conversion constant.

Line 1003 uses magic number 100 to convert cents to dollars. Per coding guidelines, extract this to a constant (e.g., CENTS_PER_DOLLAR = 100) at the top of the file or reference it from a shared config.

🔎 Proposed fix

Add near the top of the file:

+/** Conversion factor: divide cents by this to get dollars */
+const CENTS_PER_DOLLAR = 100;

Then update the calculation:

                   {new Intl.NumberFormat("en-US", {
                     style: "currency",
                     currency: "USD",
-                  }).format((AFFILIATE_CONFIG.MINIMUM_PAYOUT ?? 5000) / 100)}
+                  }).format((AFFILIATE_CONFIG.MINIMUM_PAYOUT ?? 5000) / CENTS_PER_DOLLAR)}

Note: The past review comment about hardcoded $50.00 is addressed since the code now references AFFILIATE_CONFIG.MINIMUM_PAYOUT.

As per coding guidelines.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ee42902 and e9b5650.

📒 Files selected for processing (4)
  • src/routes/admin/feature-flags.tsx
  • src/routes/affiliate-dashboard.tsx
  • src/routes/api/connect/stripe/callback/index.ts
  • src/routes/purchase.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/routes/admin/feature-flags.tsx
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx}: When invoking a TanStack server function, pass an object with a data property containing parameters
All card components should use the shadcn Card component with CardTitle, CardDescription, and other shadcn Card subcomponents
All server-side operations should use TanStack Start server functions created with createServerFn instead of direct backend logic
Form handling should use React Hook Form with Zod validation

**/*.{ts,tsx}: Never hard code magic numbers into code; consolidate them to the top of the file or in /src/config/index.ts to keep the code clean
Never allow a file to exceed 1,000 lines of code; split overly large files into smaller modular components to maintain code maintainability

Files:

  • src/routes/api/connect/stripe/callback/index.ts
  • src/routes/purchase.tsx
  • src/routes/affiliate-dashboard.tsx
**/{components,routes}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Component styling should follow shadcn/ui patterns with Tailwind CSS v4

Files:

  • src/routes/api/connect/stripe/callback/index.ts
  • src/routes/purchase.tsx
  • src/routes/affiliate-dashboard.tsx
**/routes/**/*.tsx

📄 CodeRabbit inference engine (CLAUDE.md)

**/routes/**/*.tsx: Pages should use the Page component and PageHeader component when possible
Implement error boundaries at the route level using DefaultCatchBoundary

Files:

  • src/routes/purchase.tsx
  • src/routes/affiliate-dashboard.tsx
🧠 Learnings (12)
📚 Learning: 2025-12-19T06:04:43.442Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-19T06:04:43.442Z
Learning: Applies to **/{fn,functions}/**/*.{ts,tsx} : When making a TanStack server function with optional authentication, use `.middleware([unauthenticatedMiddleware])`

Applied to files:

  • src/routes/purchase.tsx
📚 Learning: 2025-12-19T06:04:43.442Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-19T06:04:43.442Z
Learning: Applies to **/{fn,functions}/**/*.{ts,tsx} : When making a TanStack server function that requires authentication, use `.middleware([authenticatedMiddleware])`

Applied to files:

  • src/routes/purchase.tsx
📚 Learning: 2025-12-22T03:59:58.018Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: .cursor/rules/tanstack-server-functions.mdc:0-0
Timestamp: 2025-12-22T03:59:58.018Z
Learning: Applies to src/fn/**/*.ts : When creating a tanstack start server function, always include a middleware function call. Existing middleware functions are available in `src/lib/auth.ts`

Applied to files:

  • src/routes/purchase.tsx
📚 Learning: 2025-12-19T06:04:43.442Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-19T06:04:43.442Z
Learning: Applies to **/{fn,functions}/**/*.{ts,tsx} : When making a TanStack server function that requires admin-only permission, use `.middleware([adminMiddleware])`

Applied to files:

  • src/routes/purchase.tsx
📚 Learning: 2025-12-19T06:04:43.442Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-19T06:04:43.442Z
Learning: Applies to **/*.{ts,tsx} : All server-side operations should use TanStack Start server functions created with `createServerFn` instead of direct backend logic

Applied to files:

  • src/routes/purchase.tsx
📚 Learning: 2025-12-22T03:59:58.018Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: .cursor/rules/tanstack-server-functions.mdc:0-0
Timestamp: 2025-12-22T03:59:58.018Z
Learning: Applies to src/fn/**/*.ts : When using server functions, never import drizzle related objects inside the function handler. Server functions should always invoke use cases based on the layered architecture pattern. Reference `docs/technical/layered-architecture.md` for layered architecture guidelines

Applied to files:

  • src/routes/purchase.tsx
📚 Learning: 2025-12-22T03:59:58.018Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: .cursor/rules/tanstack-server-functions.mdc:0-0
Timestamp: 2025-12-22T03:59:58.018Z
Learning: Applies to src/fn/**/*.ts : When using a server function, always attach a validator to it

Applied to files:

  • src/routes/purchase.tsx
📚 Learning: 2025-12-19T06:04:43.442Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-19T06:04:43.442Z
Learning: Applies to **/routes/**/*.tsx : Implement error boundaries at the route level using `DefaultCatchBoundary`

Applied to files:

  • src/routes/purchase.tsx
  • src/routes/affiliate-dashboard.tsx
📚 Learning: 2025-12-19T06:04:47.031Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: .cursor/rules/clean-code.mdc:0-0
Timestamp: 2025-12-19T06:04:47.031Z
Learning: Applies to **/*.{ts,tsx} : Never hard code magic numbers into code; consolidate them to the top of the file or in `/src/config/index.ts` to keep the code clean

Applied to files:

  • src/routes/purchase.tsx
  • src/routes/affiliate-dashboard.tsx
📚 Learning: 2025-12-19T06:04:43.442Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-19T06:04:43.442Z
Learning: Applies to **/routes/admin/**/*.tsx : When making an admin page, protect it using `assertIsAdminFn()` in the `beforeLoad` hook with `createFileRoute`

Applied to files:

  • src/routes/affiliate-dashboard.tsx
📚 Learning: 2025-12-19T06:04:43.442Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-19T06:04:43.442Z
Learning: Applies to **/routes/**/*.tsx : Pages should use the `Page` component and `PageHeader` component when possible

Applied to files:

  • src/routes/affiliate-dashboard.tsx
📚 Learning: 2025-12-19T06:04:43.442Z
Learnt from: CR
Repo: webdevcody/agentic-jumpstart PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-12-19T06:04:43.442Z
Learning: Applies to **/*.{ts,tsx} : Form handling should use React Hook Form with Zod validation

Applied to files:

  • src/routes/affiliate-dashboard.tsx
🧬 Code graph analysis (2)
src/routes/api/connect/stripe/callback/index.ts (4)
src/utils/crypto.ts (1)
  • timingSafeStringEqual (12-30)
src/data-access/affiliates.ts (2)
  • getAffiliateByUserId (23-29)
  • updateAffiliateStripeAccount (363-410)
src/lib/stripe.ts (1)
  • stripe (4-4)
src/utils/stripe-status.ts (1)
  • determineStripeAccountStatus (24-39)
src/routes/purchase.tsx (5)
src/utils/env-public.tsx (1)
  • publicEnv (3-8)
src/data-access/affiliates.ts (1)
  • getAffiliateByCode (31-39)
src/data-access/profiles.ts (1)
  • getProfile (59-69)
src/data-access/app-settings.ts (1)
  • getPricingCurrentPrice (252-266)
src/lib/stripe.ts (1)
  • stripe (4-4)
⏰ 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). (1)
  • GitHub Check: Run Playwright Tests
🔇 Additional comments (11)
src/routes/api/connect/stripe/callback/index.ts (3)

1-13: LGTM!

Imports are appropriate for the route's functionality, and URL constants are correctly extracted to the top of the file per coding guidelines.


60-91: LGTM - Cookie lifecycle and validation logic is well-designed.

The implementation correctly:

  • Preserves cookies on transient errors (404s) to allow retry
  • Clears cookies on security violations (affiliate mismatch) to prevent reuse
  • Defers final cookie cleanup until successful completion

Note: The past review comment about "authorization code" validation doesn't apply here since this is an Account Links return callback, not an OAuth code exchange flow.


93-109: LGTM!

The Stripe account status retrieval and database update logic is correct. The implementation properly uses the determineStripeAccountStatus utility for status normalization and the atomic updateAffiliateStripeAccount function for consistent updates.

src/routes/purchase.tsx (3)

37-43: LGTM!

Constants are properly extracted to avoid magic numbers, and moving stripePromise to module scope improves performance by preventing repeated Stripe initialization.


79-99: LGTM!

Route definition now includes the required error boundary and properly uses loaderDeps with the loader.


107-235: LGTM!

The checkout logic correctly uses extracted constants and implements Stripe transfer_data for automatic affiliate payouts. The use of Math.floor for commission ensures you never overpay.

src/routes/affiliate-dashboard.tsx (5)

92-111: LGTM!

Payment form schema uses Zod 4 .refine() correctly for conditional validation, ensuring payment links are valid URLs when the link method is selected.


186-213: LGTM!

Loader efficiently fetches dashboard data, feature flags, and pricing settings in parallel using Promise.all. The comment about potential future optimization with Suspense boundaries is helpful for future maintainers.


250-310: LGTM!

All mutations follow consistent patterns with proper cache invalidation and user feedback via toasts.


858-991: LGTM!

Comprehensive Stripe Connect status handling with clear user guidance for each onboarding state. The payout error banner (862-885) provides helpful context with timestamps and recovery instructions.


702-797: LGTM!

Payment method form uses React Hook Form with proper conditional field rendering and accessible RadioGroup implementation. The visual selection states enhance UX.

Comment on lines 172 to 216
export const Route = createFileRoute("/affiliate-dashboard")({
beforeLoad: async () => {
await assertFeatureEnabled("AFFILIATES_FEATURE");
await assertAuthenticatedFn();
},
loader: async ({ context }) => {
// First check if user is an affiliate
const affiliateCheck = await context.queryClient.ensureQueryData({
queryKey: ["affiliate", "check"],
queryFn: () => checkIfUserIsAffiliateFn(),
});

// Check if onboarding is complete - redirect if not
const { data: affiliateCheck } = await checkIfUserIsAffiliateFn();
if (!affiliateCheck.isAffiliate) {
return { isAffiliate: false };
throw redirect({ to: "/affiliates" });
}
if (!affiliateCheck.isOnboardingComplete) {
throw redirect({ to: "/affiliate-onboarding" });
}
},
loader: async ({ context }) => {
// Fetch dashboard data, feature flags, and pricing settings in parallel
const [dashboardResponse, discountSplitEnabled, customPaymentLinkEnabled, pricingSettings] = await Promise.all([
context.queryClient.ensureQueryData({
queryKey: ["affiliate", "dashboard"],
queryFn: () => getAffiliateDashboardFn(),
}),
context.queryClient.ensureQueryData({
queryKey: ["featureFlag", "AFFILIATE_DISCOUNT_SPLIT"],
queryFn: () => isFeatureEnabledForUserFn({ data: { flagKey: "AFFILIATE_DISCOUNT_SPLIT" } }),
}),
context.queryClient.ensureQueryData({
queryKey: ["featureFlag", "AFFILIATE_CUSTOM_PAYMENT_LINK"],
queryFn: () => isFeatureEnabledForUserFn({ data: { flagKey: "AFFILIATE_CUSTOM_PAYMENT_LINK" } }),
}),
context.queryClient.ensureQueryData({
queryKey: ["pricing", "settings"],
queryFn: () => getPricingSettingsFn(),
}),
]);

// If they are an affiliate, get dashboard data
const data = await context.queryClient.ensureQueryData({
queryKey: ["affiliate", "dashboard"],
queryFn: () => getAffiliateDashboardFn(),
});
return { isAffiliate: true, dashboard: data };
return {
isAffiliate: true,
dashboard: dashboardResponse.data,
discountSplitEnabled,
customPaymentLinkEnabled,
pricingSettings,
};
},
component: AffiliateDashboard,
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add error boundary to route definition.

The route is missing errorComponent: DefaultCatchBoundary. Per coding guidelines, all routes should implement error boundaries.

🔎 Proposed fix
+import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary";

 export const Route = createFileRoute("/affiliate-dashboard")({
   beforeLoad: async () => {
     // ... existing code
   },
   loader: async ({ context }) => {
     // ... existing code
   },
   component: AffiliateDashboard,
+  errorComponent: DefaultCatchBoundary,
 });

As per coding guidelines.

🤖 Prompt for AI Agents
In src/routes/affiliate-dashboard.tsx around lines 172 to 216 the route
definition is missing an error boundary; add errorComponent:
DefaultCatchBoundary to the object passed into createFileRoute (e.g.
errorComponent: DefaultCatchBoundary,) and also add the matching import at the
top of the file (import DefaultCatchBoundary from
"<correct/path/to/DefaultCatchBoundary>") — update the import path to the actual
location in the codebase.

- Generate fresh presigned URLs in data-access layer instead of storing
- Profile images now persist indefinitely (no 1-hour expiry)
- Upload only stores imageId (R2 key), not the presigned URL
@amoscicki amoscicki force-pushed the AM04/feat/affiliates branch 2 times, most recently from d16329f to e5f00b3 Compare December 28, 2025 20:52
amoscicki and others added 20 commits December 28, 2025 20:57
- Generate fresh presigned URLs in data-access layer instead of storing
- Profile images now persist indefinitely (no 1-hour expiry)
- Upload only stores imageId (R2 key), not the presigned URL
…mponent

- Add /admin/pricing page for managing course pricing
- Create reusable NumberInputWithControls component with +/- buttons
- Add Pricing link to admin navigation
- Fix pricing display: promo discount as main, affiliate as extra
- Update purchase page to show correct discount hierarchy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Show real referral/conversion history in Activity timeline
- Show real payout history with status colors
- Add avatar images to affiliate table and details sheet
- Add pagination support for referrals and payouts (limit/offset)
- Use NumberInputWithControls for commission rate editing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ements

- Add Stripe Connect OAuth routes for affiliate onboarding
- Update affiliate dashboard with improved layout
- Add affiliate use-cases updates
- Update schema and route tree

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update package.json and pnpm-lock.yaml
- Update vite.config.ts
- Update env.ts with new variables
- Minor UI and client updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
… button

- Limit initial load to 10 referrals and 10 payouts
- Add "Load older activity" button to fetch more
- Use useEffect for proper state management
- Track hasMore flags from paginated responses

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove complex pagination state management that caused issues
- Use direct data from queries instead of accumulated state
- Show "+ more activity..." indicator when there's more data
- Full pagination can be added later if needed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…se-case

- getAffiliateReferrals and getAffiliatePayouts now return { items, hasMore }
- Update getAffiliateAnalyticsUseCase to extract .items for dashboard

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove duplicate Payment Information / Stripe Connect cards into single card
- Show business name and account type from Stripe API
- Hide Edit button when custom payment links disabled
- Fix misleading messages (instant payouts, not $50 minimum for Stripe)
- Add stripeAccountType column to affiliates schema
- Fetch Stripe account name dynamically in dashboard use-case

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Extract logMiddleware to dedicated server-logger.ts module
- Add readable function names instead of base64 encoded IDs
- Add configurable scopes (default, payments, auth, affiliates, etc.)
- Support both backward compat (.middleware([logMiddleware])) and
  explicit scopes (.middleware([logMiddleware(LOG_SCOPES.PAYMENTS)]))
- Fix URL-safe base64 decoding for function IDs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add STRIPE_CLIENT_ID for OAuth2 Connect flow
- Add note about STRIPE_PRICE_ID being deprecated
- Remove dev patches section (moved to .dev/)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add stripeAccountStatusEnum and affiliatePaymentMethodEnum
- Add CHECK constraint ensuring discountRate <= commissionRate
- Generate migrations 0051 and 0052
- Add dedicated TOKEN_SIGNING_SECRET env var for token signing
- Create url-sanitizer.ts for XSS protection on URLs
- Update crypto.ts to use new secret instead of STRIPE_WEBHOOK_SECRET
- Refactor to use SendRawEmailCommand for MIME headers
- Add List-Unsubscribe header for better email deliverability
- GET functions now return { success: true, data: ... }
- Add feature flag middleware to Stripe account functions
- Fix pagination in data-access layer
- Update all consumers to access .data from server responses
- Fix Rate input width (w-30) to fit 100%
- Fix onboarding flow to prevent re-registration of existing affiliates
- Improve error handling and logging
- Use URL sanitizer for redirect URLs
- Remove unused imports and dead code
- Improve type safety
amoscicki and others added 8 commits December 28, 2025 20:57
- Document server function response format convention
- Add extensible server logger with scopes
- Fix affiliate payout failed email template
Add `strict` prop to FeatureFlag component and useFeatureFlag hook that
respects actual flag state even for admins. This is useful for admin
panel where we want to hide Payment Link features behind the flag
regardless of admin privileges.

- Min Payout control hidden behind AFFILIATE_CUSTOM_PAYMENT_LINK flag
- Batch Settle button hidden behind flag
- Record Payout dialog hidden behind flag

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove 'pending' from stripeAccountStatus type (not in DB schema)
- Add updatePaymentMethodFn export alias for backward compatibility
- Add missing updateAffiliateCommissionRate import
- Fix refreshStripeStatusMutation call to use { data: undefined }
- Update determineStripeAccountStatus return type to StripeAccountStatusType

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Update affiliate documentation to reflect:
- Commission rate is now configurable by admin (default 30%)
- Minimum payout is configurable and only applies to Payment Link affiliates
- Stripe Connect affiliates have no minimum threshold

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add timingSafeStringEqual for CSRF state validation in refresh route
- Remove unused imports (useSuspenseQuery, authenticatedMiddleware)
- Add PERF comment for future suspense query optimization
- Remove explicit Stripe API version to use SDK default

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- fix(feature-flags): Replace no-op stub with proper toggle function for
  VIDEO_SEGMENT_CONTENT_TABS flag that was causing "action is not a function" error

- fix(affiliate-dashboard): Replace hardcoded "$50.00" minimum payout with
  dynamic value from AFFILIATE_CONFIG using Intl.NumberFormat

- fix(stripe-callback): Defer cookie deletion until successful completion
  to allow OAuth retry on transient errors; only clear cookies on security
  violations or success

- fix(purchase): Extract magic numbers to named constants (CENTS_PER_DOLLAR,
  PERCENTAGE_DIVISOR) for clarity and maintainability

- fix(purchase): Add DefaultCatchBoundary error component to route

- perf(purchase): Move stripePromise to module level to avoid redundant
  loadStripe calls on each checkout attempt

- fix(purchase): Add missing semicolon and fix useCallback dependency array
@amoscicki amoscicki force-pushed the AM04/feat/affiliates branch from e5f00b3 to bb7821f Compare December 28, 2025 20:57
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.

1 participant