-
Notifications
You must be signed in to change notification settings - Fork 10
feat: Affiliate Program with Stripe Connect integration #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
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 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. ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (81)
📝 WalkthroughWalkthroughAdds 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
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~90+ minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
Comment |
There was a problem hiding this 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 eis too permissive. It will catch any object with atoproperty, not specifically TanStack router redirect errors. This could result in unintended re-throws if other error objects happen to have atoproperty.Consider using a more explicit check, such as testing against a specific error class or using
instanceofwith 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
minimumPayoutsetting loaded viagetAffiliateMinimumPayoutFn. 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
formatCurrencyhelper is duplicated across multiple files. See the recommended refactor inaffiliate-details-sheet.tsxto 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+techconcatenates 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
generateImageUrlhelper properly:
- Provides backward compatibility with the fallback pattern
- Handles errors gracefully
- Returns null for missing imageIds
Consider using a structured logger instead of
console.erroron 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
onErrorhandler 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 theonErrorfallback (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
onErrorpattern 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
getDicebearUrlhelper:+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.setTimeoutandwindow.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
DropdownMenuCheckboxItemwithonClickhandlers for sort actions. While this works, checkbox items are semantically intended for toggling boolean state withonCheckedChange. The sort actions (ascending/descending) are mutually exclusive choices, not independent toggles.The checked visual indicator is appropriate here, but consider using regular
DropdownMenuItemwith 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 withsrc/utils/email.ts.This interface duplicates
AffiliatePayoutSuccessEmailPropsdefined atsrc/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_idsrc/utils/logger.ts (1)
8-38: Consider protecting reserved log fields from context collision.If
contextcontains keys likelevel,timestamp, ormessage, 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.
AffiliatePayoutFailedEmailPropsis already defined insrc/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:autoFocusonly on range Calendar.The range mode Calendar has
autoFocus(line 208) but the single-date Calendar (line 219) does not. Consider addingautoFocusto 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 * 10is duplicated in bothsrc/routes/api/connect/stripe/oauth/index.tsandsrc/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.errorfor 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 againstmax- Line 117:
numValue <= max && numValue >= range[0]- doesn't explicitly check againstminWhile the conditions might work in most cases, explicitly checking both
min/maxand 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
selectedAffiliatein its body but the dependency array usesselectedAffiliate?.id. While this works for the find logic, if other properties ofselectedAffiliatewere 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.assignpattern allows bothlogMiddleware("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
InputGroupInputlacks 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, andformatShortDateare duplicated here and inaffiliates.tsx. These could be consolidated into a shared utility (e.g.,~/lib/format.tsmentioned 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
onCopyLinkandonViewLinkcallbacks 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.tsxsrc/use-cases/app-settings.ts (1)
158-176: Consider atomic transactions for multi-field updates.
updatePricingSettingsUseCaseupdates three settings concurrently viaPromise.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 indentationsrc/routes/admin/pricing.tsx (3)
24-33: Loader should return the ensureQueryData promise.The loader calls
ensureQueryDatabut 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 shadcnButtoncomponent.🔎 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 = 12is 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
ensureQueryDatabut 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
letvariable 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
timingSafeStringEqualwhich 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.tokencall returns more than juststripe_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'sbuttonVariantprop.The
Calendarcomponent accepts abuttonVariantprop (line 21) and uses it for the navigation buttons (lines 57, 62), butCalendarDayButtonalways renders withvariant="ghost"hardcoded at line 196. If the intent is to allow consistent theming, consider passing the variant through toCalendarDayButtonvia thecomponentsprop 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>withrole="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 theTableRowdirectly.🔎 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
textorplaintext: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, andgetAffiliateReferralsare data-access layer functions that should be wrapped in use-cases.Consider creating use-case wrappers like
updateAffiliateDiscountRateUseCase,getAffiliatePayoutsUseCase, andgetAffiliateReferralsUseCasein~/use-cases/affiliates.ts.
216-222: Consider extracting magic number to config.The minimum payout value
5000is hardcoded here. Per coding guidelines, magic numbers should be consolidated to config files. This value should referenceAFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUTor 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
updatePaymentMethodFnis dynamically imported but it's from the same module (~/fn/affiliates) whereregisterAffiliateFnandcheckIfUserIsAffiliateFnare 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 usingstripe-event-typesor manual type narrowing instead of string casting.The
transfer.paidandtransfer.failedevents are valid Stripe event types, but Stripe's official TypeScript types don't discriminate webhook events by default. While castingevent.typeto string works functionally, better approaches exist:
- Install the
stripe-event-typescommunity package for discriminated webhook event types- 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.tsxAs 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 thebeforeLoadguard 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
affiliatePayoutStatusEnumis defined after theaffiliateReferralstable but beforeaffiliatePayoutswhich 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 withstripeAccountStatusEnumandaffiliatePaymentMethodEnum.
| className, | ||
| ...props | ||
| }: DataTableColumnHeaderProps<TData, TValue>) { | ||
| const isRightAligned = className?.includes("ml-auto"); |
There was a problem hiding this comment.
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).
| {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> | ||
| )} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:
- A checked "Hide" item implies the column is hidden, but clicking it doesn't show it
- 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.
| {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`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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 -50Repository: 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 -40Repository: webdevcody/agentic-jumpstart
Length of output: 1258
🏁 Script executed:
#!/bin/bash
# Find header-related files
fd header src/components -o fd header src/routesRepository: 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 2Repository: 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 1Repository: 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 fRepository: 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 affiliateRepository: 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 2Repository: 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.
| // Clear cookies after successful validation | ||
| deleteCookie("stripe_connect_state"); | ||
| deleteCookie("stripe_connect_affiliate_id"); | ||
| if (onboardingInProgress) { | ||
| deleteCookie("affiliate_onboarding"); | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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}`; | ||
| } |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's look at the file structure around the Stripe routes
find . -type f -name "*.ts" -path "*stripe*" | head -20Repository: 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 -100Repository: 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 -80Repository: webdevcody/agentic-jumpstart
Length of output: 1961
🏁 Script executed:
cat -n src/routes/api/connect/stripe/refresh/index.tsRepository: webdevcody/agentic-jumpstart
Length of output: 4720
🏁 Script executed:
# Check the buildCookie helper function
rg -n "buildCookie" --type ts -B2 -A10 | head -40Repository: 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 -A2Repository: 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:
throw redirect()(preferred for simple redirects)- Manually build headers with
Set-Cookie(as used in/api/connect/stripe/index.tswith thebuildCookiehelper)
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.
| 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, | ||
| }; | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
e2c0b73 to
272e67d
Compare
There was a problem hiding this 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
50in the validation schema contradicts the configurable minimum payout feature. Consider usingAFFILIATE_CONFIG.DEFAULT_MINIMUM_PAYOUT / 100or 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. Insrc/components/ui/calendar.tsx(line 194-195),CalendarDayButtonpassesref={ref}toButton, which will silently fail sincerefis no longer a valid prop.To fix, restore ref forwarding or use the
asChildpattern 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
NuqsAdaptershould only wrap the root route (insrc/routes/__root.tsx), not the hydration point. Double-wrapping creates duplicate contexts, stale references, and potential hydration mismatches.Remove the
NuqsAdapterimport 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 explicitundefinedvalues.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 explicitundefined) 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-dashboardinstead of/affiliates. The/affiliatespath is the public affiliate program signup page, while/affiliate-dashboardis 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 duplicatedbuildCookiehelper to shared utility.The
buildCookiefunction 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 duplicateTOKEN_SIGNING_SECRETentry.Line 40 contains a duplicate
TOKEN_SIGNING_SECRET=CHANGE_MEthat will override the well-documented entry at lines 16-18 (which includes helpful generation instructions usingopenssl rand -base64 32). Most.envparsers 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 withResponse.redirect()— use manual headers.The
setCookiecalls (lines 75-90) do not automatically attach cookies toResponse.redirect(). TanStack React Start requires manually building headers withSet-Cookiefor 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. Thechecked={!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 forVIDEO_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: MissingfilterIdcomparison in filter equality check.The
filterItemSchemaincludesfilterId(line 57), but theeqfunction doesn't compare it. This could cause incorrect equality results when two filters differ only byfilterId.🔎 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.
filtersKeyandjoinOperatorKeyare defined and stored intable.meta.queryKeysbut never connected to actual query state management viauseQueryState. Compare withpageKey,perPageKey, andsortKeywhich 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.
useSuspenseQueryis 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.
getAffiliateInfoFnlacks middleware. Per coding guidelines, TanStack server functions should include middleware. For unauthenticated access, useunauthenticatedMiddleware.🔎 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
100throughout 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 allowsprice >= 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) andsetPricingOriginalPrice(allows>= 0).src/routes/affiliate-dashboard.tsx (1)
993-1001: Magic number for minimum payout display.The
$50.00minimum payout value is hardcoded. This should referenceAFFILIATE_CONFIG.MINIMUM_PAYOUTfor 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
srcattribute and theonErrorhandler. 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
Settingsicon is retained for the renamed "Feature Flags" item. Consider using a flag-related icon (e.g.,Flagfrom 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_reasonenum includes"requirements.past_due"as a value—when past_due items exist,disabled_reasonis set to this value. The current check fordisabled_reasontherefore already catches accounts with past_due fields without needing to checkrequirements.past_dueseparately.However, accounts with
requirements.currently_dueitems (fields due but not yet overdue) won't triggerdisabled_reasonand will returnONBOARDINGstatus. If you want to surface accounts needing near-term attention before they become restricted, consider adding a check forcurrently_dueitems.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+techcreates strings via implicit concatenation (e.g., "0JavaScript"). Consider using a template literal for clarity, or justtechif 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:andhttps:protocols. While this may be necessary for local development, allowinghttp: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.tsis available for structured logging. Using it instead ofconsole.errorwould 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
onBlurhandler 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.MouseEventworks 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: InconsistentautoFocusbetween modes.The range mode calendar (line 208) has
autoFocuswhile the single mode calendar (line 219) does not. Consider addingautoFocusto 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.assignpattern makeslogMiddlewareboth 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.tsas 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 redundantsanitizeImageUrlcall.
sanitizeImageUrlis called twice with the same input—once in the condition and again for thesrcattribute. 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.
animationIndexis mutated within the render function viaanimationIndex++. 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 existinguseDebouncedCallbackhook.A manual
setTimeout-based debounce is implemented here, but the codebase already has auseDebouncedCallbackhook (used inuse-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
12percent 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 shadcnButtoncomponent 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 addingaria-selectedto CommandItem for screen reader support.While the visual check indicator is present, screen readers would benefit from
aria-selected={isSelected}on eachCommandItemto 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
updatePricingSettingsFnvalidates individual prices but doesn't enforce thatoriginalPrice >= 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 movingformatCurrencyto a shared utility.This helper duplicates common currency formatting logic. Based on the AI summary mentioning
formatCurrencyinsrc/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: RedundantsanitizeImageUrlcall — cache the result.
sanitizeImageUrl(affiliate.userImage)is called twice: once for the conditional check (line 78) and again for thesrcattribute (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 bothTableHead(lines 63-66) andTableCell(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
SameSiteandSecureattributes. 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.paidandtransfer.failedevents 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
checkoutFnduplicates logic fromgetAffiliateInfoFn. 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 ononValueCommit.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.
| ``` | ||
| 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 | ||
| ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| ``` | ||
| Admin Dashboard → Select Affiliate → "Process Automatic Payout" | ||
| ``` | ||
|
|
||
| **Batch Processing:** | ||
| ``` | ||
| Admin Dashboard → "Process All Automatic Payouts" → Review Results | ||
| ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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=5Repository: 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').
| interface AffiliatePayoutSuccessEmailProps { | ||
| affiliateName: string; | ||
| payoutAmount: string; | ||
| payoutDate: string; | ||
| stripeTransferId: string; | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| const detailRow = { | ||
| display: "flex", | ||
| justifyContent: "space-between", | ||
| marginBottom: "12px", | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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
- Campaign Monitor — email client CSS support (flexbox notes). [turn0search4]
- Email-Dev / Email compatibility guide (flexbox unsupported; table fallbacks recommended). [turn0search3]
- MailLinter compatibility notes (flexbox high-impact issue; use tables). [turn0search2]
- React Email docs — Section/Row/Column/Container components and client testing notes. [turn1search4][turn1search9]
- React Email components (Column/Row docs). [turn1search5][turn1search7]
Use React Email's Row/Column components instead of inline flexbox styles.
display: "flex"andjustifyContent: "space-between"ondetailRowwon't work reliably in Outlook and many older email clients. Instead, use React Email's built-inRowandColumncomponents (orSection) 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.
| 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 }; | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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.
| useEffect(() => { | ||
| if (search.stripeComplete && search.method) { | ||
| setSelectedMethod(search.method); | ||
| setCurrentStep("terms"); | ||
| } | ||
| }, [search.stripeComplete, search.method]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| export const logger = { | ||
| info: (message: string, context?: LogContext) => { | ||
| console.log( | ||
| JSON.stringify({ | ||
| level: "info", | ||
| timestamp: new Date().toISOString(), | ||
| message, | ||
| ...context, | ||
| }) | ||
| ); | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this 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_secretAnd add a blank line at the end of the file after line 38.
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 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_IDandTOKEN_SIGNING_SECRETwith clear generation instructions supports the OAuth onboarding and token-signing flows described in the PR objectives. The comment onSTRIPE_PRICE_IDcorrectly notes that dynamic payment intents may supersede its use.Verify that
STRIPE_PRICE_IDis either still required by the codebase or can be safely removed in a future cleanup.Also applies to: 16-18
There was a problem hiding this 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.
getAffiliateInfoFnlacks 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 ofconsole.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
100for percentage calculations. Per coding guidelines, use thePERCENTAGE_DIVISORconstant 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
100for 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
100to 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.00is addressed since the code now referencesAFFILIATE_CONFIG.MINIMUM_PAYOUT.As per coding guidelines.
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
src/routes/admin/feature-flags.tsxsrc/routes/affiliate-dashboard.tsxsrc/routes/api/connect/stripe/callback/index.tssrc/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 adataproperty containing parameters
All card components should use the shadcnCardcomponent withCardTitle,CardDescription, and other shadcn Card subcomponents
All server-side operations should use TanStack Start server functions created withcreateServerFninstead 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.tsto 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.tssrc/routes/purchase.tsxsrc/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.tssrc/routes/purchase.tsxsrc/routes/affiliate-dashboard.tsx
**/routes/**/*.tsx
📄 CodeRabbit inference engine (CLAUDE.md)
**/routes/**/*.tsx: Pages should use thePagecomponent andPageHeadercomponent when possible
Implement error boundaries at the route level usingDefaultCatchBoundary
Files:
src/routes/purchase.tsxsrc/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.tsxsrc/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.tsxsrc/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
determineStripeAccountStatusutility for status normalization and the atomicupdateAffiliateStripeAccountfunction for consistent updates.src/routes/purchase.tsx (3)
37-43: LGTM!Constants are properly extracted to avoid magic numbers, and moving
stripePromiseto module scope improves performance by preventing repeated Stripe initialization.
79-99: LGTM!Route definition now includes the required error boundary and properly uses
loaderDepswith 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.floorfor 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.
| 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, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
d16329f to
e5f00b3
Compare
- 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
- 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
e5f00b3 to
bb7821f
Compare
Summary
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 dashboardAffiliate 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 commissionKey Changes
Affiliate Program Core
/purchase?ref={code}tracking linksStripe Connect Integration
not_started→onboarding→pending→activeaccount.updatedevents to sync status/api/connect/stripe,/api/connect/stripe/callback,/api/connect/stripe/refreshAffiliate Dashboard
Admin Management
Infrastructure
NumberInputWithControlscomponent for admin settingsDatabase
stripeAccountTypecolumn for tracking account typesTest Plan
npm run testSummary by CodeRabbit
New Features
Documentation
Tests
✏️ Tip: You can customize this high-level summary in your review settings.