diff --git a/.agents/skills/vercel-react-native-skills/AGENTS.md b/.agents/skills/vercel-react-native-skills/AGENTS.md deleted file mode 100644 index d263eb9c1..000000000 --- a/.agents/skills/vercel-react-native-skills/AGENTS.md +++ /dev/null @@ -1,2897 +0,0 @@ -# React Native Skills - -**Version 1.0.0** -Engineering -January 2026 - -> **Note:** -> This document is mainly for agents and LLMs to follow when maintaining, -> generating, or refactoring React Native codebases. Humans -> may also find it useful, but guidance here is optimized for automation -> and consistency by AI-assisted workflows. - ---- - -## Abstract - -Comprehensive performance optimization guide for React Native applications, designed for AI agents and LLMs. Contains 35+ rules across 13 categories, prioritized by impact from critical (core rendering, list performance) to incremental (fonts, imports). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. - ---- - -## Table of Contents - -1. [Core Rendering](#1-core-rendering) — **CRITICAL** - - 1.1 [Never Use && with Potentially Falsy Values](#11-never-use--with-potentially-falsy-values) - - 1.2 [Wrap Strings in Text Components](#12-wrap-strings-in-text-components) -2. [List Performance](#2-list-performance) — **HIGH** - - 2.1 [Avoid Inline Objects in renderItem](#21-avoid-inline-objects-in-renderitem) - - 2.2 [Hoist callbacks to the root of lists](#22-hoist-callbacks-to-the-root-of-lists) - - 2.3 [Keep List Items Lightweight](#23-keep-list-items-lightweight) - - 2.4 [Optimize List Performance with Stable Object References](#24-optimize-list-performance-with-stable-object-references) - - 2.5 [Pass Primitives to List Items for Memoization](#25-pass-primitives-to-list-items-for-memoization) - - 2.6 [Use a List Virtualizer for Any List](#26-use-a-list-virtualizer-for-any-list) - - 2.7 [Use Compressed Images in Lists](#27-use-compressed-images-in-lists) - - 2.8 [Use Item Types for Heterogeneous Lists](#28-use-item-types-for-heterogeneous-lists) -3. [Animation](#3-animation) — **HIGH** - - 3.1 [Animate Transform and Opacity Instead of Layout Properties](#31-animate-transform-and-opacity-instead-of-layout-properties) - - 3.2 [Prefer useDerivedValue Over useAnimatedReaction](#32-prefer-usederivedvalue-over-useanimatedreaction) - - 3.3 [Use GestureDetector for Animated Press States](#33-use-gesturedetector-for-animated-press-states) -4. [Scroll Performance](#4-scroll-performance) — **HIGH** - - 4.1 [Never Track Scroll Position in useState](#41-never-track-scroll-position-in-usestate) -5. [Navigation](#5-navigation) — **HIGH** - - 5.1 [Use Native Navigators for Navigation](#51-use-native-navigators-for-navigation) -6. [React State](#6-react-state) — **MEDIUM** - - 6.1 [Minimize State Variables and Derive Values](#61-minimize-state-variables-and-derive-values) - - 6.2 [Use fallback state instead of initialState](#62-use-fallback-state-instead-of-initialstate) - - 6.3 [useState Dispatch updaters for State That Depends on Current Value](#63-usestate-dispatch-updaters-for-state-that-depends-on-current-value) -7. [State Architecture](#7-state-architecture) — **MEDIUM** - - 7.1 [State Must Represent Ground Truth](#71-state-must-represent-ground-truth) -8. [React Compiler](#8-react-compiler) — **MEDIUM** - - 8.1 [Destructure Functions Early in Render (React Compiler)](#81-destructure-functions-early-in-render-react-compiler) - - 8.2 [Use .get() and .set() for Reanimated Shared Values (not .value)](#82-use-get-and-set-for-reanimated-shared-values-not-value) -9. [User Interface](#9-user-interface) — **MEDIUM** - - 9.1 [Measuring View Dimensions](#91-measuring-view-dimensions) - - 9.2 [Modern React Native Styling Patterns](#92-modern-react-native-styling-patterns) - - 9.3 [Use contentInset for Dynamic ScrollView Spacing](#93-use-contentinset-for-dynamic-scrollview-spacing) - - 9.4 [Use contentInsetAdjustmentBehavior for Safe Areas](#94-use-contentinsetadjustmentbehavior-for-safe-areas) - - 9.5 [Use expo-image for Optimized Images](#95-use-expo-image-for-optimized-images) - - 9.6 [Use Galeria for Image Galleries and Lightbox](#96-use-galeria-for-image-galleries-and-lightbox) - - 9.7 [Use Native Menus for Dropdowns and Context Menus](#97-use-native-menus-for-dropdowns-and-context-menus) - - 9.8 [Use Native Modals Over JS-Based Bottom Sheets](#98-use-native-modals-over-js-based-bottom-sheets) - - 9.9 [Use Pressable Instead of Touchable Components](#99-use-pressable-instead-of-touchable-components) -10. [Design System](#10-design-system) — **MEDIUM** - - 10.1 [Use Compound Components Over Polymorphic Children](#101-use-compound-components-over-polymorphic-children) -11. [Monorepo](#11-monorepo) — **LOW** - - 11.1 [Install Native Dependencies in App Directory](#111-install-native-dependencies-in-app-directory) - - 11.2 [Use Single Dependency Versions Across Monorepo](#112-use-single-dependency-versions-across-monorepo) -12. [Third-Party Dependencies](#12-third-party-dependencies) — **LOW** - - 12.1 [Import from Design System Folder](#121-import-from-design-system-folder) -13. [JavaScript](#13-javascript) — **LOW** - - 13.1 [Hoist Intl Formatter Creation](#131-hoist-intl-formatter-creation) -14. [Fonts](#14-fonts) — **LOW** - - 14.1 [Load fonts natively at build time](#141-load-fonts-natively-at-build-time) - ---- - -## 1. Core Rendering - -**Impact: CRITICAL** - -Fundamental React Native rendering rules. Violations cause -runtime crashes or broken UI. - -### 1.1 Never Use && with Potentially Falsy Values - -**Impact: CRITICAL (prevents production crash)** - -Never use `{value && }` when `value` could be an empty string or - -`0`. These are falsy but JSX-renderable—React Native will try to render them as - -text outside a `` component, causing a hard crash in production. - -**Incorrect: crashes if count is 0 or name is ""** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {name && {name}} - {count && {count} items} - - ) -} -// If name="" or count=0, renders the falsy value → crash -``` - -**Correct: ternary with null** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {name ? {name} : null} - {count ? {count} items : null} - - ) -} -``` - -**Correct: explicit boolean coercion** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {!!name && {name}} - {!!count && {count} items} - - ) -} -``` - -**Best: early return** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - if (!name) return null - - return ( - - {name} - {count > 0 ? {count} items : null} - - ) -} -``` - -Early returns are clearest. When using conditionals inline, prefer ternary or - -explicit boolean checks. - -**Lint rule:** Enable `react/jsx-no-leaked-render` from - -[eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-leaked-render.md) - -to catch this automatically. - -### 1.2 Wrap Strings in Text Components - -**Impact: CRITICAL (prevents runtime crash)** - -Strings must be rendered inside ``. React Native crashes if a string is a - -direct child of ``. - -**Incorrect: crashes** - -```tsx -import { View } from 'react-native' - -function Greeting({ name }: { name: string }) { - return Hello, {name}! -} -// Error: Text strings must be rendered within a component. -``` - -**Correct:** - -```tsx -import { View, Text } from 'react-native' - -function Greeting({ name }: { name: string }) { - return ( - - Hello, {name}! - - ) -} -``` - ---- - -## 2. List Performance - -**Impact: HIGH** - -Optimizing virtualized lists (FlatList, LegendList, FlashList) -for smooth scrolling and fast updates. - -### 2.1 Avoid Inline Objects in renderItem - -**Impact: HIGH (prevents unnecessary re-renders of memoized list items)** - -Don't create new objects inside `renderItem` to pass as props. Inline objects - -create new references on every render, breaking memoization. Pass primitive - -values directly from `item` instead. - -**Incorrect: inline object breaks memoization** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - - )} - /> - ) -} -``` - -**Incorrect: inline style object** - -```tsx -renderItem={({ item }) => ( - -)} -``` - -**Correct: pass item directly or primitives** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - // Good: pass the item directly - - )} - /> - ) -} -``` - -**Correct: pass primitives, derive inside child** - -```tsx -renderItem={({ item }) => ( - -)} - -const UserRow = memo(function UserRow({ id, name, isActive }: Props) { - // Good: derive style inside memoized component - const backgroundColor = isActive ? 'green' : 'gray' - return {/* ... */} -}) -``` - -**Correct: hoist static styles in module scope** - -```tsx -const activeStyle = { backgroundColor: 'green' } -const inactiveStyle = { backgroundColor: 'gray' } - -renderItem={({ item }) => ( - -)} -``` - -Passing primitives or stable references allows `memo()` to skip re-renders when - -the actual values haven't changed. - -**Note:** If you have the React Compiler enabled, it handles memoization - -automatically and these manual optimizations become less critical. - -### 2.2 Hoist callbacks to the root of lists - -**Impact: MEDIUM (Fewer re-renders and faster lists)** - -When passing callback functions to list items, create a single instance of the - -callback at the root of the list. Items should then call it with a unique - -identifier. - -**Incorrect: creates a new callback on each render** - -```typescript -return ( - { - // bad: creates a new callback on each render - const onPress = () => handlePress(item.id) - return - }} - /> -) -``` - -**Correct: a single function instance passed to each item** - -```typescript -const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id]) - -return ( - ( - - )} - /> -) -``` - -Reference: [https://example.com](https://example.com) - -### 2.3 Keep List Items Lightweight - -**Impact: HIGH (reduces render time for visible items during scroll)** - -List items should be as inexpensive as possible to render. Minimize hooks, avoid - -queries, and limit React Context access. Virtualized lists render many items - -during scroll—expensive items cause jank. - -**Incorrect: heavy list item** - -```tsx -function ProductRow({ id }: { id: string }) { - // Bad: query inside list item - const { data: product } = useQuery(['product', id], () => fetchProduct(id)) - // Bad: multiple context accesses - const theme = useContext(ThemeContext) - const user = useContext(UserContext) - const cart = useContext(CartContext) - // Bad: expensive computation - const recommendations = useMemo( - () => computeRecommendations(product), - [product] - ) - - return {/* ... */} -} -``` - -**Correct: lightweight list item** - -```tsx -function ProductRow({ name, price, imageUrl }: Props) { - // Good: receives only primitives, minimal hooks - return ( - - - {name} - {price} - - ) -} -``` - -**Move data fetching to parent:** - -```tsx -// Parent fetches all data once -function ProductList() { - const { data: products } = useQuery(['products'], fetchProducts) - - return ( - ( - - )} - /> - ) -} -``` - -**For shared values, use Zustand selectors instead of Context:** - -```tsx -// Incorrect: Context causes re-render when any cart value changes -function ProductRow({ id, name }: Props) { - const { items } = useContext(CartContext) - const inCart = items.includes(id) - // ... -} - -// Correct: Zustand selector only re-renders when this specific value changes -function ProductRow({ id, name }: Props) { - // use Set.has (created once at the root) instead of Array.includes() - const inCart = useCartStore((s) => s.items.has(id)) - // ... -} -``` - -**Guidelines for list items:** - -- No queries or data fetching - -- No expensive computations (move to parent or memoize at parent level) - -- Prefer Zustand selectors over React Context - -- Minimize useState/useEffect hooks - -- Pass pre-computed values as props - -The goal: list items should be simple rendering functions that take props and - -return JSX. - -### 2.4 Optimize List Performance with Stable Object References - -**Impact: CRITICAL (virtualization relies on reference stability)** - -Don't map or filter data before passing to virtualized lists. Virtualization - -relies on object reference stability to know what changed—new references cause - -full re-renders of all visible items. Attempt to prevent frequent renders at the - -list-parent level. - -Where needed, use context selectors within list items. - -**Incorrect: creates new object references on every keystroke** - -```tsx -function DomainSearch() { - const { keyword, setKeyword } = useKeywordZustandState() - const { data: tlds } = useTlds() - - // Bad: creates new objects on every render, reparenting the entire list on every keystroke - const domains = tlds.map((tld) => ({ - domain: `${keyword}.${tld.name}`, - tld: tld.name, - price: tld.price, - })) - - return ( - <> - - } - /> - - ) -} -``` - -**Correct: stable references, transform inside items** - -```tsx -const renderItem = ({ item }) => - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // good: transform within items, and don't pass the dynamic data as a prop - // good: use a selector function from zustand to receive a stable string back - const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name) - return {domain} -} -``` - -**Updating parent array reference:** - -```tsx -// good: creates a new array instance without mutating the inner objects -// good: parent array reference is unaffected by typing and updating "keyword" -const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name)) - -return -``` - -Creating a new array instance can be okay, as long as its inner object - -references are stable. For instance, if you sort a list of objects: - -Even though this creates a new array instance `sortedTlds`, the inner object - -references are stable. - -**With zustand for dynamic data: avoids parent re-renders** - -```tsx -function DomainItemFavoriteButton({ tld }: { tld: Tld }) { - const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id)) - return -} -``` - -Virtualization can now skip items that haven't changed when typing. Only visible - -items (~20) re-render on keystroke, rather than the parent. - -**Deriving state within list items based on parent data (avoids parent - -re-renders):** - -For components where the data is conditional based on the parent state, this - -pattern is even more important. For example, if you are checking if an item is - -favorited, toggling favorites only re-renders one component if the item itself - -is in charge of accessing the state rather than the parent: - -Note: if you're using the React Compiler, you can read React Context values - -directly within list items. Although this is slightly slower than using a - -Zustand selector in most cases, the effect may be negligible. - -### 2.5 Pass Primitives to List Items for Memoization - -**Impact: HIGH (enables effective memo() comparison)** - -When possible, pass only primitive values (strings, numbers, booleans) as props - -to list item components. Primitives enable shallow comparison in `memo()` to - -work correctly, skipping re-renders when values haven't changed. - -**Incorrect: object prop requires deep comparison** - -```tsx -type User = { id: string; name: string; email: string; avatar: string } - -const UserRow = memo(function UserRow({ user }: { user: User }) { - // memo() compares user by reference, not value - // If parent creates new user object, this re-renders even if data is same - return {user.name} -}) - -renderItem={({ item }) => } -``` - -This can still be optimized, but it is harder to memoize properly. - -**Correct: primitive props enable shallow comparison** - -```tsx -const UserRow = memo(function UserRow({ - id, - name, - email, -}: { - id: string - name: string - email: string -}) { - // memo() compares each primitive directly - // Re-renders only if id, name, or email actually changed - return {name} -}) - -renderItem={({ item }) => ( - -)} -``` - -**Pass only what you need:** - -```tsx -// Incorrect: passing entire item when you only need name - - -// Correct: pass only the fields the component uses - -``` - -**For callbacks, hoist or use item ID:** - -```tsx -// Incorrect: inline function creates new reference - handlePress(item.id)} /> - -// Correct: pass ID, handle in child - - -const UserRow = memo(function UserRow({ id, name }: Props) { - const handlePress = useCallback(() => { - // use id here - }, [id]) - return {name} -}) -``` - -Primitive props make memoization predictable and effective. - -**Note:** If you have the React Compiler enabled, you do not need to use - -`memo()` or `useCallback()`, but the object references still apply. - -### 2.6 Use a List Virtualizer for Any List - -**Impact: HIGH (reduced memory, faster mounts)** - -Use a list virtualizer like LegendList or FlashList instead of ScrollView with - -mapped children—even for short lists. Virtualizers only render visible items, - -reducing memory usage and mount time. ScrollView renders all children upfront, - -which gets expensive quickly. - -**Incorrect: ScrollView renders all items at once** - -```tsx -function Feed({ items }: { items: Item[] }) { - return ( - - {items.map((item) => ( - - ))} - - ) -} -// 50 items = 50 components mounted, even if only 10 visible -``` - -**Correct: virtualizer renders only visible items** - -```tsx -import { LegendList } from '@legendapp/list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - estimatedItemSize={80} - /> - ) -} -// Only ~10-15 visible items mounted at a time -``` - -**Alternative: FlashList** - -```tsx -import { FlashList } from '@shopify/flash-list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - /> - ) -} -``` - -Benefits apply to any screen with scrollable content—profiles, settings, feeds, - -search results. Default to virtualization. - -### 2.7 Use Compressed Images in Lists - -**Impact: HIGH (faster load times, less memory)** - -Always load compressed, appropriately-sized images in lists. Full-resolution - -images consume excessive memory and cause scroll jank. Request thumbnails from - -your server or use an image CDN with resize parameters. - -**Incorrect: full-resolution images** - -```tsx -function ProductItem({ product }: { product: Product }) { - return ( - - {/* 4000x3000 image loaded for a 100x100 thumbnail */} - - {product.name} - - ) -} -``` - -**Correct: request appropriately-sized image** - -```tsx -function ProductItem({ product }: { product: Product }) { - // Request a 200x200 image (2x for retina) - const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover` - - return ( - - - {product.name} - - ) -} -``` - -Use an optimized image component with built-in caching and placeholder support, - -such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood). - -Request images at 2x the display size for retina screens. - -### 2.8 Use Item Types for Heterogeneous Lists - -**Impact: HIGH (efficient recycling, less layout thrashing)** - -When a list has different item layouts (messages, images, headers, etc.), use a - -`type` field on each item and provide `getItemType` to the list. This puts items - -into separate recycling pools so a message component never gets recycled into an - -image component. - -[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2) - -**Incorrect: single component with conditionals** - -```tsx -type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean } - -function ListItem({ item }: { item: Item }) { - if (item.isHeader) { - return - } - if (item.imageUrl) { - return - } - return -} - -function Feed({ items }: { items: Item[] }) { - return ( - } - recycleItems - /> - ) -} -``` - -**Correct: typed items with separate components** - -```tsx -type HeaderItem = { id: string; type: 'header'; title: string } -type MessageItem = { id: string; type: 'message'; text: string } -type ImageItem = { id: string; type: 'image'; url: string } -type FeedItem = HeaderItem | MessageItem | ImageItem - -function Feed({ items }: { items: FeedItem[] }) { - return ( - item.id} - getItemType={(item) => item.type} - renderItem={({ item }) => { - switch (item.type) { - case 'header': - return - case 'message': - return - case 'image': - return - } - }} - recycleItems - /> - ) -} -``` - -**Why this matters:** - -```tsx - item.id} - getItemType={(item) => item.type} - getEstimatedItemSize={(index, item, itemType) => { - switch (itemType) { - case 'header': - return 48 - case 'message': - return 72 - case 'image': - return 300 - default: - return 72 - } - }} - renderItem={({ item }) => { - /* ... */ - }} - recycleItems -/> -``` - -- **Recycling efficiency**: Items with the same type share a recycling pool - -- **No layout thrashing**: A header never recycles into an image cell - -- **Type safety**: TypeScript can narrow the item type in each branch - -- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for - - accurate estimates per type - ---- - -## 3. Animation - -**Impact: HIGH** - -GPU-accelerated animations, Reanimated patterns, and avoiding -render thrashing during gestures. - -### 3.1 Animate Transform and Opacity Instead of Layout Properties - -**Impact: HIGH (GPU-accelerated animations, no layout recalculation)** - -Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout. - -**Incorrect: animates height, triggers layout every frame** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - height: withTiming(expanded ? 200 : 0), // triggers layout on every frame - overflow: 'hidden', - })) - - return {children} -} -``` - -**Correct: animates scaleY, GPU-accelerated** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scaleY: withTiming(expanded ? 1 : 0) }, - ], - opacity: withTiming(expanded ? 1 : 0), - })) - - return ( - - {children} - - ) -} -``` - -**Correct: animates translateY for slide animations** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function SlideIn({ visible }: { visible: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { translateY: withTiming(visible ? 0 : 100) }, - ], - opacity: withTiming(visible ? 1 : 0), - })) - - return {children} -} -``` - -GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout. - -### 3.2 Prefer useDerivedValue Over useAnimatedReaction - -**Impact: MEDIUM (cleaner code, automatic dependency tracking)** - -When deriving a shared value from another, use `useDerivedValue` instead of - -`useAnimatedReaction`. Derived values are declarative, automatically track - -dependencies, and return a value you can use directly. Animated reactions are - -for side effects, not derivations. - -[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue) - -**Incorrect: useAnimatedReaction for derivation** - -```tsx -import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - const opacity = useSharedValue(1) - - useAnimatedReaction( - () => progress.value, - (current) => { - opacity.value = 1 - current - } - ) - - // ... -} -``` - -**Correct: useDerivedValue** - -```tsx -import { useSharedValue, useDerivedValue } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - - const opacity = useDerivedValue(() => 1 - progress.get()) - - // ... -} -``` - -Use `useAnimatedReaction` only for side effects that don't produce a value - -(e.g., triggering haptics, logging, calling `runOnJS`). - -### 3.3 Use GestureDetector for Animated Press States - -**Impact: MEDIUM (UI thread animations, smoother press feedback)** - -For animated press states (scale, opacity on press), use `GestureDetector` with - -`Gesture.Tap()` and shared values instead of Pressable's - -`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no - -JS thread round-trip for press animations. - -[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture) - -**Incorrect: Pressable with JS thread callbacks** - -```tsx -import { Pressable } from 'react-native' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - const scale = useSharedValue(1) - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })) - - return ( - (scale.value = withTiming(0.95))} - onPressOut={() => (scale.value = withTiming(1))} - > - - Press me - - - ) -} -``` - -**Correct: GestureDetector with UI thread worklets** - -```tsx -import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - interpolate, - runOnJS, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - // Store the press STATE (0 = not pressed, 1 = pressed) - const pressed = useSharedValue(0) - - const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - .onEnd(() => { - runOnJS(onPress)() - }) - - // Derive visual values from the state - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) }, - ], - })) - - return ( - - - Press me - - - ) -} -``` - -Store the press **state** (0 or 1), then derive the scale via `interpolate`. - -This keeps the shared value as ground truth. Use `runOnJS` to call JS functions - -from worklets. Use `.set()` and `.get()` for React Compiler compatibility. - ---- - -## 4. Scroll Performance - -**Impact: HIGH** - -Tracking scroll position without causing render thrashing. - -### 4.1 Never Track Scroll Position in useState - -**Impact: HIGH (prevents render thrashing during scroll)** - -Never store scroll position in `useState`. Scroll events fire rapidly—state - -updates cause render thrashing and dropped frames. Use a Reanimated shared value - -for animations or a ref for non-reactive tracking. - -**Incorrect: useState causes jank** - -```tsx -import { useState } from 'react' -import { - ScrollView, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native' - -function Feed() { - const [scrollY, setScrollY] = useState(0) - - const onScroll = (e: NativeSyntheticEvent) => { - setScrollY(e.nativeEvent.contentOffset.y) // re-renders on every frame - } - - return -} -``` - -**Correct: Reanimated for animations** - -```tsx -import Animated, { - useSharedValue, - useAnimatedScrollHandler, -} from 'react-native-reanimated' - -function Feed() { - const scrollY = useSharedValue(0) - - const onScroll = useAnimatedScrollHandler({ - onScroll: (e) => { - scrollY.value = e.contentOffset.y // runs on UI thread, no re-render - }, - }) - - return ( - - ) -} -``` - -**Correct: ref for non-reactive tracking** - -```tsx -import { useRef } from 'react' -import { - ScrollView, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native' - -function Feed() { - const scrollY = useRef(0) - - const onScroll = (e: NativeSyntheticEvent) => { - scrollY.current = e.nativeEvent.contentOffset.y // no re-render - } - - return -} -``` - ---- - -## 5. Navigation - -**Impact: HIGH** - -Using native navigators for stack and tab navigation instead of -JS-based alternatives. - -### 5.1 Use Native Navigators for Navigation - -**Impact: HIGH (native performance, platform-appropriate UI)** - -Always use native navigators instead of JS-based ones. Native navigators use - -platform APIs (UINavigationController on iOS, Fragment on Android) for better - -performance and native behavior. - -**For stacks:** Use `@react-navigation/native-stack` or expo-router's default - -stack (which uses native-stack). Avoid `@react-navigation/stack`. - -**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native - -tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters. - -- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator) - -- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation) - -- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router) - -- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs) - -**Incorrect: JS stack navigator** - -```tsx -import { createStackNavigator } from '@react-navigation/stack' - -const Stack = createStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: native stack with react-navigation** - -```tsx -import { createNativeStackNavigator } from '@react-navigation/native-stack' - -const Stack = createNativeStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: expo-router uses native stack by default** - -```tsx -// app/_layout.tsx -import { Stack } from 'expo-router' - -export default function Layout() { - return -} -``` - -**Incorrect: JS bottom tabs** - -```tsx -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' - -const Tab = createBottomTabNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: native bottom tabs with react-navigation** - -```tsx -import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation' - -const Tab = createNativeBottomTabNavigator() - -function App() { - return ( - - ({ sfSymbol: 'house' }), - }} - /> - ({ sfSymbol: 'gear' }), - }} - /> - - ) -} -``` - -**Correct: expo-router native tabs** - -```tsx -// app/(tabs)/_layout.tsx -import { NativeTabs } from 'expo-router/unstable-native-tabs' - -export default function TabLayout() { - return ( - - - Home - - - - Settings - - - - ) -} -``` - -On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the - -first `ScrollView` at the root of each tab screen, so content scrolls correctly - -behind the translucent tab bar. If you need to disable this, use - -`disableAutomaticContentInsets` on the trigger. - -**Incorrect: custom header component** - -```tsx - , - }} -/> -``` - -**Correct: native header options** - -```tsx - -``` - -Native headers support iOS large titles, search bars, blur effects, and proper - -safe area handling automatically. - -- **Performance**: Native transitions and gestures run on the UI thread - -- **Platform behavior**: Automatic iOS large titles, Android material design - -- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe - - areas - -- **Accessibility**: Platform accessibility features work automatically - ---- - -## 6. React State - -**Impact: MEDIUM** - -Patterns for managing React state to avoid stale closures and -unnecessary re-renders. - -### 6.1 Minimize State Variables and Derive Values - -**Impact: MEDIUM (fewer re-renders, less state drift)** - -Use the fewest state variables possible. If a value can be computed from existing state or props, derive it during render instead of storing it in state. Redundant state causes unnecessary re-renders and can drift out of sync. - -**Incorrect: redundant state** - -```tsx -function Cart({ items }: { items: Item[] }) { - const [total, setTotal] = useState(0) - const [itemCount, setItemCount] = useState(0) - - useEffect(() => { - setTotal(items.reduce((sum, item) => sum + item.price, 0)) - setItemCount(items.length) - }, [items]) - - return ( - - {itemCount} items - Total: ${total} - - ) -} -``` - -**Correct: derived values** - -```tsx -function Cart({ items }: { items: Item[] }) { - const total = items.reduce((sum, item) => sum + item.price, 0) - const itemCount = items.length - - return ( - - {itemCount} items - Total: ${total} - - ) -} -``` - -**Another example:** - -```tsx -// Incorrect: storing both firstName, lastName, AND fullName -const [firstName, setFirstName] = useState('') -const [lastName, setLastName] = useState('') -const [fullName, setFullName] = useState('') - -// Correct: derive fullName -const [firstName, setFirstName] = useState('') -const [lastName, setLastName] = useState('') -const fullName = `${firstName} ${lastName}` -``` - -State should be the minimal source of truth. Everything else is derived. - -Reference: [https://react.dev/learn/choosing-the-state-structure](https://react.dev/learn/choosing-the-state-structure) - -### 6.2 Use fallback state instead of initialState - -**Impact: MEDIUM (reactive fallbacks without syncing)** - -Use `undefined` as initial state and nullish coalescing (`??`) to fall back to - -parent or server values. State represents user intent only—`undefined` means - -"user hasn't chosen yet." This enables reactive fallbacks that update when the - -source changes, not just on initial render. - -**Incorrect: syncs state, loses reactivity** - -```tsx -type Props = { fallbackEnabled: boolean } - -function Toggle({ fallbackEnabled }: Props) { - const [enabled, setEnabled] = useState(defaultEnabled) - // If fallbackEnabled changes, state is stale - // State mixes user intent with default value - - return -} -``` - -**Correct: state is user intent, reactive fallback** - -```tsx -type Props = { fallbackEnabled: boolean } - -function Toggle({ fallbackEnabled }: Props) { - const [_enabled, setEnabled] = useState(undefined) - const enabled = _enabled ?? defaultEnabled - // undefined = user hasn't touched it, falls back to prop - // If defaultEnabled changes, component reflects it - // Once user interacts, their choice persists - - return -} -``` - -**With server data:** - -```tsx -function ProfileForm({ data }: { data: User }) { - const [_theme, setTheme] = useState(undefined) - const theme = _theme ?? data.theme - // Shows server value until user overrides - // Server refetch updates the fallback automatically - - return -} -``` - -### 6.3 useState Dispatch updaters for State That Depends on Current Value - -**Impact: MEDIUM (avoids stale closures, prevents unnecessary re-renders)** - -When the next state depends on the current state, use a dispatch updater - -(`setState(prev => ...)`) instead of reading the state variable directly in a - -callback. This avoids stale closures and ensures you're comparing against the - -latest value. - -**Incorrect: reads state directly** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - // size may be stale in this closure - if (size?.width !== width || size?.height !== height) { - setSize({ width, height }) - } -} -``` - -**Correct: dispatch updater** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize((prev) => { - if (prev?.width === width && prev?.height === height) return prev - return { width, height } - }) -} -``` - -Returning the previous value from the updater skips the re-render. - -For primitive states, you don't need to compare values before firing a - -re-render. - -**Incorrect: unnecessary comparison for primitive state** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize((prev) => (prev === width ? prev : width)) -} -``` - -**Correct: sets primitive state directly** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize(width) -} -``` - -However, if the next state depends on the current state, you should still use a - -dispatch updater. - -**Incorrect: reads state directly from the callback** - -```tsx -const [count, setCount] = useState(0) - -const onTap = () => { - setCount(count + 1) -} -``` - -**Correct: dispatch updater** - -```tsx -const [count, setCount] = useState(0) - -const onTap = () => { - setCount((prev) => prev + 1) -} -``` - ---- - -## 7. State Architecture - -**Impact: MEDIUM** - -Ground truth principles for state variables and derived values. - -### 7.1 State Must Represent Ground Truth - -**Impact: HIGH (cleaner logic, easier debugging, single source of truth)** - -State variables—both React `useState` and Reanimated shared values—should - -represent the actual state of something (e.g., `pressed`, `progress`, `isOpen`), - -not derived visual values (e.g., `scale`, `opacity`, `translateY`). Derive - -visual values from state using computation or interpolation. - -**Incorrect: storing the visual output** - -```tsx -const scale = useSharedValue(1) - -const tap = Gesture.Tap() - .onBegin(() => { - scale.set(withTiming(0.95)) - }) - .onFinalize(() => { - scale.set(withTiming(1)) - }) - -const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.get() }], -})) -``` - -**Correct: storing the state, deriving the visual** - -```tsx -const pressed = useSharedValue(0) // 0 = not pressed, 1 = pressed - -const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - -const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }], -})) -``` - -**Why this matters:** - -State variables should represent real "state", not necessarily a desired end - -result. - -1. **Single source of truth** — The state (`pressed`) describes what's - - happening; visuals are derived - -2. **Easier to extend** — Adding opacity, rotation, or other effects just - - requires more interpolations from the same state - -3. **Debugging** — Inspecting `pressed = 1` is clearer than `scale = 0.95` - -4. **Reusable logic** — The same `pressed` value can drive multiple visual - - properties - -**Same principle for React state:** - -```tsx -// Incorrect: storing derived values -const [isExpanded, setIsExpanded] = useState(false) -const [height, setHeight] = useState(0) - -useEffect(() => { - setHeight(isExpanded ? 200 : 0) -}, [isExpanded]) - -// Correct: derive from state -const [isExpanded, setIsExpanded] = useState(false) -const height = isExpanded ? 200 : 0 -``` - -State is the minimal truth. Everything else is derived. - ---- - -## 8. React Compiler - -**Impact: MEDIUM** - -Compatibility patterns for React Compiler with React Native and -Reanimated. - -### 8.1 Destructure Functions Early in Render (React Compiler) - -**Impact: HIGH (stable references, fewer re-renders)** - -This rule is only applicable if you are using the React Compiler. - -Destructure functions from hooks at the top of render scope. Never dot into - -objects to call functions. Destructured functions are stable references; dotting - -creates new references and breaks memoization. - -**Incorrect: dotting into object** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton(props) { - const router = useRouter() - - // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render - const handlePress = () => { - props.onSave() - router.push('/success') // unstable reference - } - - return -} -``` - -**Correct: destructure early** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton({ onSave }) { - const { push } = useRouter() - - // good: react-compiler will key on push and onSave - const handlePress = () => { - onSave() - push('/success') // stable reference - } - - return -} -``` - -### 8.2 Use .get() and .set() for Reanimated Shared Values (not .value) - -**Impact: LOW (required for React Compiler compatibility)** - -With React Compiler enabled, use `.get()` and `.set()` instead of reading or - -writing `.value` directly on Reanimated shared values. The compiler can't track - -property access—explicit methods ensure correct behavior. - -**Incorrect: breaks with React Compiler** - -```tsx -import { useSharedValue } from 'react-native-reanimated' - -function Counter() { - const count = useSharedValue(0) - - const increment = () => { - count.value = count.value + 1 // opts out of react compiler - } - - return - -``` - -**Correct: compound components** - -```tsx -import { Pressable, Text } from 'react-native' - -function Button({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonText({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonIcon({ children }: { children: React.ReactNode }) { - return <>{children} -} - -// Usage is explicit and composable - - - -``` - ---- - -## 11. Monorepo - -**Impact: LOW** - -Dependency management and native module configuration in -monorepos. - -### 11.1 Install Native Dependencies in App Directory - -**Impact: CRITICAL (required for autolinking to work)** - -In a monorepo, packages with native code must be installed in the native app's - -directory directly. Autolinking only scans the app's `node_modules`—it won't - -find native dependencies installed in other packages. - -**Incorrect: native dep in shared package only** - -```typescript -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # missing react-native-reanimated -``` - -Autolinking fails—native code not linked. - -**Correct: native dep in app directory** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Even if the shared package uses the native dependency, the app must also list it - -for autolinking to detect and link the native code. - -### 11.2 Use Single Dependency Versions Across Monorepo - -**Impact: MEDIUM (avoids duplicate bundles, version conflicts)** - -Use a single version of each dependency across all packages in your monorepo. - -Prefer exact versions over ranges. Multiple versions cause duplicate code in - -bundles, runtime conflicts, and inconsistent behavior across packages. - -Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions - -or npm overrides. - -**Incorrect: version ranges, multiple versions** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.0.0" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.5.0" - } -} -``` - -**Correct: exact versions, single source of truth** - -```json -// package.json (root) -{ - "pnpm": { - "overrides": { - "react-native-reanimated": "3.16.1" - } - } -} - -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Use your package manager's override/resolution feature to enforce versions at - -the root. When adding dependencies, specify exact versions without `^` or `~`. - ---- - -## 12. Third-Party Dependencies - -**Impact: LOW** - -Wrapping and re-exporting third-party dependencies for -maintainability. - -### 12.1 Import from Design System Folder - -**Impact: LOW (enables global changes and easy refactoring)** - -Re-export dependencies from a design system folder. App code imports from there, - -not directly from packages. This enables global changes and easy refactoring. - -**Incorrect: imports directly from package** - -```tsx -import { View, Text } from 'react-native' -import { Button } from '@ui/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -**Correct: imports from design system** - -```tsx -import { View } from '@/components/view' -import { Text } from '@/components/text' -import { Button } from '@/components/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -Start by simply re-exporting. Customize later without changing app code. - ---- - -## 13. JavaScript - -**Impact: LOW** - -Micro-optimizations like hoisting expensive object creation. - -### 13.1 Hoist Intl Formatter Creation - -**Impact: LOW-MEDIUM (avoids expensive object recreation)** - -Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or - -`Intl.RelativeTimeFormat` inside render or loops. These are expensive to - -instantiate. Hoist to module scope when the locale/options are static. - -**Incorrect: new formatter every render** - -```tsx -function Price({ amount }: { amount: number }) { - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }) - return {formatter.format(amount)} -} -``` - -**Correct: hoisted to module scope** - -```tsx -const currencyFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}) - -function Price({ amount }: { amount: number }) { - return {currencyFormatter.format(amount)} -} -``` - -**For dynamic locales, memoize:** - -```tsx -const dateFormatter = useMemo( - () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }), - [locale] -) -``` - -**Common formatters to hoist:** - -```tsx -// Module-level formatters -const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }) -const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }) -const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' }) -const relativeFormatter = new Intl.RelativeTimeFormat('en-US', { - numeric: 'auto', -}) -``` - -Creating `Intl` objects is significantly more expensive than `RegExp` or plain - -objects—each instantiation parses locale data and builds internal lookup tables. - ---- - -## 14. Fonts - -**Impact: LOW** - -Native font loading for improved performance. - -### 14.1 Load fonts natively at build time - -**Impact: LOW (fonts available at launch, no async loading)** - -Use the `expo-font` config plugin to embed fonts at build time instead of - -`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient. - -[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/) - -**Incorrect: async font loading** - -```tsx -import { useFonts } from 'expo-font' -import { Text, View } from 'react-native' - -function App() { - const [fontsLoaded] = useFonts({ - 'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'), - }) - - if (!fontsLoaded) { - return null - } - - return ( - - Hello - - ) -} -``` - -**Correct: config plugin, fonts embedded at build** - -```tsx -import { Text, View } from 'react-native' - -function App() { - // No loading state needed—font is already available - return ( - - Hello - - ) -} -``` - -After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the - -native app. - ---- - -## References - -1. [https://react.dev](https://react.dev) -2. [https://reactnative.dev](https://reactnative.dev) -3. [https://docs.swmansion.com/react-native-reanimated](https://docs.swmansion.com/react-native-reanimated) -4. [https://docs.swmansion.com/react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler) -5. [https://docs.expo.dev](https://docs.expo.dev) -6. [https://legendapp.com/open-source/legend-list](https://legendapp.com/open-source/legend-list) -7. [https://github.com/nandorojo/galeria](https://github.com/nandorojo/galeria) -8. [https://zeego.dev](https://zeego.dev) diff --git a/.agents/skills/vercel-react-native-skills/SKILL.md b/.agents/skills/vercel-react-native-skills/SKILL.md deleted file mode 100644 index 73401865d..000000000 --- a/.agents/skills/vercel-react-native-skills/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: vercel-react-native-skills -description: - React Native and Expo best practices for building performant mobile apps. Use - when building React Native components, optimizing list performance, - implementing animations, or working with native modules. Triggers on tasks - involving React Native, Expo, mobile performance, or native platform APIs. -license: MIT -metadata: - author: vercel - version: '1.0.0' ---- - -# React Native Skills - -Comprehensive best practices for React Native and Expo applications. Contains -rules across multiple categories covering performance, animations, UI patterns, -and platform-specific optimizations. - -## When to Apply - -Reference these guidelines when: - -- Building React Native or Expo apps -- Optimizing list and scroll performance -- Implementing animations with Reanimated -- Working with images and media -- Configuring native modules or fonts -- Structuring monorepo projects with native dependencies - -## Rule Categories by Priority - -| Priority | Category | Impact | Prefix | -| -------- | ---------------- | -------- | -------------------- | -| 1 | List Performance | CRITICAL | `list-performance-` | -| 2 | Animation | HIGH | `animation-` | -| 3 | Navigation | HIGH | `navigation-` | -| 4 | UI Patterns | HIGH | `ui-` | -| 5 | State Management | MEDIUM | `react-state-` | -| 6 | Rendering | MEDIUM | `rendering-` | -| 7 | Monorepo | MEDIUM | `monorepo-` | -| 8 | Configuration | LOW | `fonts-`, `imports-` | - -## Quick Reference - -### 1. List Performance (CRITICAL) - -- `list-performance-virtualize` - Use FlashList for large lists -- `list-performance-item-memo` - Memoize list item components -- `list-performance-callbacks` - Stabilize callback references -- `list-performance-inline-objects` - Avoid inline style objects -- `list-performance-function-references` - Extract functions outside render -- `list-performance-images` - Optimize images in lists -- `list-performance-item-expensive` - Move expensive work outside items -- `list-performance-item-types` - Use item types for heterogeneous lists - -### 2. Animation (HIGH) - -- `animation-gpu-properties` - Animate only transform and opacity -- `animation-derived-value` - Use useDerivedValue for computed animations -- `animation-gesture-detector-press` - Use Gesture.Tap instead of Pressable - -### 3. Navigation (HIGH) - -- `navigation-native-navigators` - Use native stack and native tabs over JS navigators - -### 4. UI Patterns (HIGH) - -- `ui-expo-image` - Use expo-image for all images -- `ui-image-gallery` - Use Galeria for image lightboxes -- `ui-pressable` - Use Pressable over TouchableOpacity -- `ui-safe-area-scroll` - Handle safe areas in ScrollViews -- `ui-scrollview-content-inset` - Use contentInset for headers -- `ui-menus` - Use native context menus -- `ui-native-modals` - Use native modals when possible -- `ui-measure-views` - Use onLayout, not measure() -- `ui-styling` - Use StyleSheet.create or Nativewind - -### 5. State Management (MEDIUM) - -- `react-state-minimize` - Minimize state subscriptions -- `react-state-dispatcher` - Use dispatcher pattern for callbacks -- `react-state-fallback` - Show fallback on first render -- `react-compiler-destructure-functions` - Destructure for React Compiler -- `react-compiler-reanimated-shared-values` - Handle shared values with compiler - -### 6. Rendering (MEDIUM) - -- `rendering-text-in-text-component` - Wrap text in Text components -- `rendering-no-falsy-and` - Avoid falsy && for conditional rendering - -### 7. Monorepo (MEDIUM) - -- `monorepo-native-deps-in-app` - Keep native dependencies in app package -- `monorepo-single-dependency-versions` - Use single versions across packages - -### 8. Configuration (LOW) - -- `fonts-config-plugin` - Use config plugins for custom fonts -- `imports-design-system-folder` - Organize design system imports -- `js-hoist-intl` - Hoist Intl object creation - -## How to Use - -Read individual rule files for detailed explanations and code examples: - -``` -rules/list-performance-virtualize.md -rules/animation-gpu-properties.md -``` - -Each rule file contains: - -- Brief explanation of why it matters -- Incorrect code example with explanation -- Correct code example with explanation -- Additional context and references - -## Full Compiled Document - -For the complete guide with all rules expanded: `AGENTS.md` diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md b/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md deleted file mode 100644 index 310928a95..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Prefer useDerivedValue Over useAnimatedReaction -impact: MEDIUM -impactDescription: cleaner code, automatic dependency tracking -tags: animation, reanimated, derived-value ---- - -## Prefer useDerivedValue Over useAnimatedReaction - -When deriving a shared value from another, use `useDerivedValue` instead of -`useAnimatedReaction`. Derived values are declarative, automatically track -dependencies, and return a value you can use directly. Animated reactions are -for side effects, not derivations. - -**Incorrect (useAnimatedReaction for derivation):** - -```tsx -import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - const opacity = useSharedValue(1) - - useAnimatedReaction( - () => progress.value, - (current) => { - opacity.value = 1 - current - } - ) - - // ... -} -``` - -**Correct (useDerivedValue):** - -```tsx -import { useSharedValue, useDerivedValue } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - - const opacity = useDerivedValue(() => 1 - progress.get()) - - // ... -} -``` - -Use `useAnimatedReaction` only for side effects that don't produce a value -(e.g., triggering haptics, logging, calling `runOnJS`). - -Reference: -[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue) diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md b/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md deleted file mode 100644 index 87c678270..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Use GestureDetector for Animated Press States -impact: MEDIUM -impactDescription: UI thread animations, smoother press feedback -tags: animation, gestures, press, reanimated ---- - -## Use GestureDetector for Animated Press States - -For animated press states (scale, opacity on press), use `GestureDetector` with -`Gesture.Tap()` and shared values instead of Pressable's -`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no -JS thread round-trip for press animations. - -**Incorrect (Pressable with JS thread callbacks):** - -```tsx -import { Pressable } from 'react-native' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - const scale = useSharedValue(1) - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })) - - return ( - (scale.value = withTiming(0.95))} - onPressOut={() => (scale.value = withTiming(1))} - > - - Press me - - - ) -} -``` - -**Correct (GestureDetector with UI thread worklets):** - -```tsx -import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - interpolate, - runOnJS, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - // Store the press STATE (0 = not pressed, 1 = pressed) - const pressed = useSharedValue(0) - - const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - .onEnd(() => { - runOnJS(onPress)() - }) - - // Derive visual values from the state - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) }, - ], - })) - - return ( - - - Press me - - - ) -} -``` - -Store the press **state** (0 or 1), then derive the scale via `interpolate`. -This keeps the shared value as ground truth. Use `runOnJS` to call JS functions -from worklets. Use `.set()` and `.get()` for React Compiler compatibility. - -Reference: -[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture) diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md b/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md deleted file mode 100644 index 5fda09558..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Animate Transform and Opacity Instead of Layout Properties -impact: HIGH -impactDescription: GPU-accelerated animations, no layout recalculation -tags: animation, performance, reanimated, transform, opacity ---- - -## Animate Transform and Opacity Instead of Layout Properties - -Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout. - -**Incorrect (animates height, triggers layout every frame):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - height: withTiming(expanded ? 200 : 0), // triggers layout on every frame - overflow: 'hidden', - })) - - return {children} -} -``` - -**Correct (animates scaleY, GPU-accelerated):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scaleY: withTiming(expanded ? 1 : 0) }, - ], - opacity: withTiming(expanded ? 1 : 0), - })) - - return ( - - {children} - - ) -} -``` - -**Correct (animates translateY for slide animations):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function SlideIn({ visible }: { visible: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { translateY: withTiming(visible ? 0 : 100) }, - ], - opacity: withTiming(visible ? 1 : 0), - })) - - return {children} -} -``` - -GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout. diff --git a/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md b/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md deleted file mode 100644 index d8239ee1f..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Use Compound Components Over Polymorphic Children -impact: MEDIUM -impactDescription: flexible composition, clearer API -tags: design-system, components, composition ---- - -## Use Compound Components Over Polymorphic Children - -Don't create components that can accept a string if they aren't a text node. If -a component can receive a string child, it must be a dedicated `*Text` -component. For components like buttons, which can have both a View (or -Pressable) together with text, use compound components, such a `Button`, -`ButtonText`, and `ButtonIcon`. - -**Incorrect (polymorphic children):** - -```tsx -import { Pressable, Text } from 'react-native' - -type ButtonProps = { - children: string | React.ReactNode - icon?: React.ReactNode -} - -function Button({ children, icon }: ButtonProps) { - return ( - - {icon} - {typeof children === 'string' ? {children} : children} - - ) -} - -// Usage is ambiguous - - -``` - -**Correct (compound components):** - -```tsx -import { Pressable, Text } from 'react-native' - -function Button({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonText({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonIcon({ children }: { children: React.ReactNode }) { - return <>{children} -} - -// Usage is explicit and composable - - - -``` diff --git a/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md b/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md deleted file mode 100644 index 39aa01477..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Load fonts natively at build time -impact: LOW -impactDescription: fonts available at launch, no async loading -tags: fonts, expo, performance, config-plugin ---- - -## Use Expo Config Plugin for Font Loading - -Use the `expo-font` config plugin to embed fonts at build time instead of -`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient. - -**Incorrect (async font loading):** - -```tsx -import { useFonts } from 'expo-font' -import { Text, View } from 'react-native' - -function App() { - const [fontsLoaded] = useFonts({ - 'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'), - }) - - if (!fontsLoaded) { - return null - } - - return ( - - Hello - - ) -} -``` - -**Correct (config plugin, fonts embedded at build):** - -```json -// app.json -{ - "expo": { - "plugins": [ - [ - "expo-font", - { - "fonts": ["./assets/fonts/Geist-Bold.otf"] - } - ] - ] - } -} -``` - -```tsx -import { Text, View } from 'react-native' - -function App() { - // No loading state needed—font is already available - return ( - - Hello - - ) -} -``` - -After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the -native app. - -Reference: -[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/) diff --git a/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md b/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md deleted file mode 100644 index 8466dcb25..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: Import from Design System Folder -impact: LOW -impactDescription: enables global changes and easy refactoring -tags: imports, architecture, design-system ---- - -## Import from Design System Folder - -Re-export dependencies from a design system folder. App code imports from there, -not directly from packages. This enables global changes and easy refactoring. - -**Incorrect (imports directly from package):** - -```tsx -import { View, Text } from 'react-native' -import { Button } from '@ui/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -**Correct (imports from design system):** - -```tsx -// components/view.tsx -import { View as RNView } from 'react-native' - -// ideal: pick the props you will actually use to control implementation -export function View( - props: Pick, 'style' | 'children'> -) { - return -} -``` - -```tsx -// components/text.tsx -export { Text } from 'react-native' -``` - -```tsx -// components/button.tsx -export { Button } from '@ui/button' -``` - -```tsx -import { View } from '@/components/view' -import { Text } from '@/components/text' -import { Button } from '@/components/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -Start by simply re-exporting. Customize later without changing app code. diff --git a/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md b/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md deleted file mode 100644 index 9af1c354c..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Hoist Intl Formatter Creation -impact: LOW-MEDIUM -impactDescription: avoids expensive object recreation -tags: javascript, intl, optimization, memoization ---- - -## Hoist Intl Formatter Creation - -Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or -`Intl.RelativeTimeFormat` inside render or loops. These are expensive to -instantiate. Hoist to module scope when the locale/options are static. - -**Incorrect (new formatter every render):** - -```tsx -function Price({ amount }: { amount: number }) { - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }) - return {formatter.format(amount)} -} -``` - -**Correct (hoisted to module scope):** - -```tsx -const currencyFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}) - -function Price({ amount }: { amount: number }) { - return {currencyFormatter.format(amount)} -} -``` - -**For dynamic locales, memoize:** - -```tsx -const dateFormatter = useMemo( - () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }), - [locale] -) -``` - -**Common formatters to hoist:** - -```tsx -// Module-level formatters -const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }) -const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }) -const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' }) -const relativeFormatter = new Intl.RelativeTimeFormat('en-US', { - numeric: 'auto', -}) -``` - -Creating `Intl` objects is significantly more expensive than `RegExp` or plain -objects—each instantiation parses locale data and builds internal lookup tables. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md deleted file mode 100644 index a0b3913ff..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Hoist callbacks to the root of lists -impact: MEDIUM -impactDescription: Fewer re-renders and faster lists -tags: tag1, tag2 ---- - -## List performance callbacks - -**Impact: HIGH (Fewer re-renders and faster lists)** - -When passing callback functions to list items, create a single instance of the -callback at the root of the list. Items should then call it with a unique -identifier. - -**Incorrect (creates a new callback on each render):** - -```typescript -return ( - { - // bad: creates a new callback on each render - const onPress = () => handlePress(item.id) - return - }} - /> -) -``` - -**Correct (a single function instance passed to each item):** - -```typescript -const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id]) - -return ( - ( - - )} - /> -) -``` - -Reference: [Link to documentation or resource](https://example.com) diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md deleted file mode 100644 index 9721929b2..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: Optimize List Performance with Stable Object References -impact: CRITICAL -impactDescription: virtualization relies on reference stability -tags: lists, performance, flatlist, virtualization ---- - -## Optimize List Performance with Stable Object References - -Don't map or filter data before passing to virtualized lists. Virtualization -relies on object reference stability to know what changed—new references cause -full re-renders of all visible items. Attempt to prevent frequent renders at the -list-parent level. - -Where needed, use context selectors within list items. - -**Incorrect (creates new object references on every keystroke):** - -```tsx -function DomainSearch() { - const { keyword, setKeyword } = useKeywordZustandState() - const { data: tlds } = useTlds() - - // Bad: creates new objects on every render, reparenting the entire list on every keystroke - const domains = tlds.map((tld) => ({ - domain: `${keyword}.${tld.name}`, - tld: tld.name, - price: tld.price, - })) - - return ( - <> - - } - /> - - ) -} -``` - -**Correct (stable references, transform inside items):** - -```tsx -const renderItem = ({ item }) => - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // good: transform within items, and don't pass the dynamic data as a prop - // good: use a selector function from zustand to receive a stable string back - const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name) - return {domain} -} -``` - -**Updating parent array reference:** - -Creating a new array instance can be okay, as long as its inner object -references are stable. For instance, if you sort a list of objects: - -```tsx -// good: creates a new array instance without mutating the inner objects -// good: parent array reference is unaffected by typing and updating "keyword" -const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name)) - -return -``` - -Even though this creates a new array instance `sortedTlds`, the inner object -references are stable. - -**With zustand for dynamic data (avoids parent re-renders):** - -```tsx -const useSearchStore = create<{ keyword: string }>(() => ({ keyword: '' })) - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - <> - - } - /> - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // Select only what you need—component only re-renders when keyword changes - const keyword = useSearchStore((s) => s.keyword) - const domain = `${keyword}.${tld.name}` - return {domain} -} -``` - -Virtualization can now skip items that haven't changed when typing. Only visible -items (~20) re-render on keystroke, rather than the parent. - -**Deriving state within list items based on parent data (avoids parent -re-renders):** - -For components where the data is conditional based on the parent state, this -pattern is even more important. For example, if you are checking if an item is -favorited, toggling favorites only re-renders one component if the item itself -is in charge of accessing the state rather than the parent: - -```tsx -function DomainItemFavoriteButton({ tld }: { tld: Tld }) { - const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id)) - return -} -``` - -Note: if you're using the React Compiler, you can read React Context values -directly within list items. Although this is slightly slower than using a -Zustand selector in most cases, the effect may be negligible. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md deleted file mode 100644 index 75a3bafc8..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Use Compressed Images in Lists -impact: HIGH -impactDescription: faster load times, less memory -tags: lists, images, performance, optimization ---- - -## Use Compressed Images in Lists - -Always load compressed, appropriately-sized images in lists. Full-resolution -images consume excessive memory and cause scroll jank. Request thumbnails from -your server or use an image CDN with resize parameters. - -**Incorrect (full-resolution images):** - -```tsx -function ProductItem({ product }: { product: Product }) { - return ( - - {/* 4000x3000 image loaded for a 100x100 thumbnail */} - - {product.name} - - ) -} -``` - -**Correct (request appropriately-sized image):** - -```tsx -function ProductItem({ product }: { product: Product }) { - // Request a 200x200 image (2x for retina) - const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover` - - return ( - - - {product.name} - - ) -} -``` - -Use an optimized image component with built-in caching and placeholder support, -such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood). -Request images at 2x the display size for retina screens. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md deleted file mode 100644 index d5b6514a6..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Avoid Inline Objects in renderItem -impact: HIGH -impactDescription: prevents unnecessary re-renders of memoized list items -tags: lists, performance, flatlist, virtualization, memo ---- - -## Avoid Inline Objects in renderItem - -Don't create new objects inside `renderItem` to pass as props. Inline objects -create new references on every render, breaking memoization. Pass primitive -values directly from `item` instead. - -**Incorrect (inline object breaks memoization):** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - - )} - /> - ) -} -``` - -**Incorrect (inline style object):** - -```tsx -renderItem={({ item }) => ( - -)} -``` - -**Correct (pass item directly or primitives):** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - // Good: pass the item directly - - )} - /> - ) -} -``` - -**Correct (pass primitives, derive inside child):** - -```tsx -renderItem={({ item }) => ( - -)} - -const UserRow = memo(function UserRow({ id, name, isActive }: Props) { - // Good: derive style inside memoized component - const backgroundColor = isActive ? 'green' : 'gray' - return {/* ... */} -}) -``` - -**Correct (hoist static styles in module scope):** - -```tsx -const activeStyle = { backgroundColor: 'green' } -const inactiveStyle = { backgroundColor: 'gray' } - -renderItem={({ item }) => ( - -)} -``` - -Passing primitives or stable references allows `memo()` to skip re-renders when -the actual values haven't changed. - -**Note:** If you have the React Compiler enabled, it handles memoization -automatically and these manual optimizations become less critical. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md deleted file mode 100644 index f617a76d4..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: Keep List Items Lightweight -impact: HIGH -impactDescription: reduces render time for visible items during scroll -tags: lists, performance, virtualization, hooks ---- - -## Keep List Items Lightweight - -List items should be as inexpensive as possible to render. Minimize hooks, avoid -queries, and limit React Context access. Virtualized lists render many items -during scroll—expensive items cause jank. - -**Incorrect (heavy list item):** - -```tsx -function ProductRow({ id }: { id: string }) { - // Bad: query inside list item - const { data: product } = useQuery(['product', id], () => fetchProduct(id)) - // Bad: multiple context accesses - const theme = useContext(ThemeContext) - const user = useContext(UserContext) - const cart = useContext(CartContext) - // Bad: expensive computation - const recommendations = useMemo( - () => computeRecommendations(product), - [product] - ) - - return {/* ... */} -} -``` - -**Correct (lightweight list item):** - -```tsx -function ProductRow({ name, price, imageUrl }: Props) { - // Good: receives only primitives, minimal hooks - return ( - - - {name} - {price} - - ) -} -``` - -**Move data fetching to parent:** - -```tsx -// Parent fetches all data once -function ProductList() { - const { data: products } = useQuery(['products'], fetchProducts) - - return ( - ( - - )} - /> - ) -} -``` - -**For shared values, use Zustand selectors instead of Context:** - -```tsx -// Incorrect: Context causes re-render when any cart value changes -function ProductRow({ id, name }: Props) { - const { items } = useContext(CartContext) - const inCart = items.includes(id) - // ... -} - -// Correct: Zustand selector only re-renders when this specific value changes -function ProductRow({ id, name }: Props) { - // use Set.has (created once at the root) instead of Array.includes() - const inCart = useCartStore((s) => s.items.has(id)) - // ... -} -``` - -**Guidelines for list items:** - -- No queries or data fetching -- No expensive computations (move to parent or memoize at parent level) -- Prefer Zustand selectors over React Context -- Minimize useState/useEffect hooks -- Pass pre-computed values as props - -The goal: list items should be simple rendering functions that take props and -return JSX. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md deleted file mode 100644 index 634935e8e..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Pass Primitives to List Items for Memoization -impact: HIGH -impactDescription: enables effective memo() comparison -tags: lists, performance, memo, primitives ---- - -## Pass Primitives to List Items for Memoization - -When possible, pass only primitive values (strings, numbers, booleans) as props -to list item components. Primitives enable shallow comparison in `memo()` to -work correctly, skipping re-renders when values haven't changed. - -**Incorrect (object prop requires deep comparison):** - -```tsx -type User = { id: string; name: string; email: string; avatar: string } - -const UserRow = memo(function UserRow({ user }: { user: User }) { - // memo() compares user by reference, not value - // If parent creates new user object, this re-renders even if data is same - return {user.name} -}) - -renderItem={({ item }) => } -``` - -This can still be optimized, but it is harder to memoize properly. - -**Correct (primitive props enable shallow comparison):** - -```tsx -const UserRow = memo(function UserRow({ - id, - name, - email, -}: { - id: string - name: string - email: string -}) { - // memo() compares each primitive directly - // Re-renders only if id, name, or email actually changed - return {name} -}) - -renderItem={({ item }) => ( - -)} -``` - -**Pass only what you need:** - -```tsx -// Incorrect: passing entire item when you only need name - - -// Correct: pass only the fields the component uses - -``` - -**For callbacks, hoist or use item ID:** - -```tsx -// Incorrect: inline function creates new reference - handlePress(item.id)} /> - -// Correct: pass ID, handle in child - - -const UserRow = memo(function UserRow({ id, name }: Props) { - const handlePress = useCallback(() => { - // use id here - }, [id]) - return {name} -}) -``` - -Primitive props make memoization predictable and effective. - -**Note:** If you have the React Compiler enabled, you do not need to use -`memo()` or `useCallback()`, but the object references still apply. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md deleted file mode 100644 index 1027e4e6c..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: Use Item Types for Heterogeneous Lists -impact: HIGH -impactDescription: efficient recycling, less layout thrashing -tags: list, performance, recycling, heterogeneous, LegendList ---- - -## Use Item Types for Heterogeneous Lists - -When a list has different item layouts (messages, images, headers, etc.), use a -`type` field on each item and provide `getItemType` to the list. This puts items -into separate recycling pools so a message component never gets recycled into an -image component. - -**Incorrect (single component with conditionals):** - -```tsx -type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean } - -function ListItem({ item }: { item: Item }) { - if (item.isHeader) { - return - } - if (item.imageUrl) { - return - } - return -} - -function Feed({ items }: { items: Item[] }) { - return ( - } - recycleItems - /> - ) -} -``` - -**Correct (typed items with separate components):** - -```tsx -type HeaderItem = { id: string; type: 'header'; title: string } -type MessageItem = { id: string; type: 'message'; text: string } -type ImageItem = { id: string; type: 'image'; url: string } -type FeedItem = HeaderItem | MessageItem | ImageItem - -function Feed({ items }: { items: FeedItem[] }) { - return ( - item.id} - getItemType={(item) => item.type} - renderItem={({ item }) => { - switch (item.type) { - case 'header': - return - case 'message': - return - case 'image': - return - } - }} - recycleItems - /> - ) -} -``` - -**Why this matters:** - -- **Recycling efficiency**: Items with the same type share a recycling pool -- **No layout thrashing**: A header never recycles into an image cell -- **Type safety**: TypeScript can narrow the item type in each branch -- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for - accurate estimates per type - -```tsx - item.id} - getItemType={(item) => item.type} - getEstimatedItemSize={(index, item, itemType) => { - switch (itemType) { - case 'header': - return 48 - case 'message': - return 72 - case 'image': - return 300 - default: - return 72 - } - }} - renderItem={({ item }) => { - /* ... */ - }} - recycleItems -/> -``` - -Reference: -[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2) diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md deleted file mode 100644 index 8a393ba1e..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Use a List Virtualizer for Any List -impact: HIGH -impactDescription: reduced memory, faster mounts -tags: lists, performance, virtualization, scrollview ---- - -## Use a List Virtualizer for Any List - -Use a list virtualizer like LegendList or FlashList instead of ScrollView with -mapped children—even for short lists. Virtualizers only render visible items, -reducing memory usage and mount time. ScrollView renders all children upfront, -which gets expensive quickly. - -**Incorrect (ScrollView renders all items at once):** - -```tsx -function Feed({ items }: { items: Item[] }) { - return ( - - {items.map((item) => ( - - ))} - - ) -} -// 50 items = 50 components mounted, even if only 10 visible -``` - -**Correct (virtualizer renders only visible items):** - -```tsx -import { LegendList } from '@legendapp/list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - estimatedItemSize={80} - /> - ) -} -// Only ~10-15 visible items mounted at a time -``` - -**Alternative (FlashList):** - -```tsx -import { FlashList } from '@shopify/flash-list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - /> - ) -} -``` - -Benefits apply to any screen with scrollable content—profiles, settings, feeds, -search results. Default to virtualization. diff --git a/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md b/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md deleted file mode 100644 index ff85d7673..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Install Native Dependencies in App Directory -impact: CRITICAL -impactDescription: required for autolinking to work -tags: monorepo, native, autolinking, installation ---- - -## Install Native Dependencies in App Directory - -In a monorepo, packages with native code must be installed in the native app's -directory directly. Autolinking only scans the app's `node_modules`—it won't -find native dependencies installed in other packages. - -**Incorrect (native dep in shared package only):** - -``` -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # missing react-native-reanimated -``` - -Autolinking fails—native code not linked. - -**Correct (native dep in app directory):** - -``` -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # also has react-native-reanimated -``` - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Even if the shared package uses the native dependency, the app must also list it -for autolinking to detect and link the native code. diff --git a/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md b/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md deleted file mode 100644 index 1087dfa51..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Use Single Dependency Versions Across Monorepo -impact: MEDIUM -impactDescription: avoids duplicate bundles, version conflicts -tags: monorepo, dependencies, installation ---- - -## Use Single Dependency Versions Across Monorepo - -Use a single version of each dependency across all packages in your monorepo. -Prefer exact versions over ranges. Multiple versions cause duplicate code in -bundles, runtime conflicts, and inconsistent behavior across packages. - -Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions -or npm overrides. - -**Incorrect (version ranges, multiple versions):** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.0.0" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.5.0" - } -} -``` - -**Correct (exact versions, single source of truth):** - -```json -// package.json (root) -{ - "pnpm": { - "overrides": { - "react-native-reanimated": "3.16.1" - } - } -} - -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Use your package manager's override/resolution feature to enforce versions at -the root. When adding dependencies, specify exact versions without `^` or `~`. diff --git a/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md b/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md deleted file mode 100644 index 035c5fd37..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md +++ /dev/null @@ -1,188 +0,0 @@ ---- -title: Use Native Navigators for Navigation -impact: HIGH -impactDescription: native performance, platform-appropriate UI -tags: navigation, react-navigation, expo-router, native-stack, tabs ---- - -## Use Native Navigators for Navigation - -Always use native navigators instead of JS-based ones. Native navigators use -platform APIs (UINavigationController on iOS, Fragment on Android) for better -performance and native behavior. - -**For stacks:** Use `@react-navigation/native-stack` or expo-router's default -stack (which uses native-stack). Avoid `@react-navigation/stack`. - -**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native -tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters. - -### Stack Navigation - -**Incorrect (JS stack navigator):** - -```tsx -import { createStackNavigator } from '@react-navigation/stack' - -const Stack = createStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (native stack with react-navigation):** - -```tsx -import { createNativeStackNavigator } from '@react-navigation/native-stack' - -const Stack = createNativeStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (expo-router uses native stack by default):** - -```tsx -// app/_layout.tsx -import { Stack } from 'expo-router' - -export default function Layout() { - return -} -``` - -### Tab Navigation - -**Incorrect (JS bottom tabs):** - -```tsx -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' - -const Tab = createBottomTabNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (native bottom tabs with react-navigation):** - -```tsx -import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation' - -const Tab = createNativeBottomTabNavigator() - -function App() { - return ( - - ({ sfSymbol: 'house' }), - }} - /> - ({ sfSymbol: 'gear' }), - }} - /> - - ) -} -``` - -**Correct (expo-router native tabs):** - -```tsx -// app/(tabs)/_layout.tsx -import { NativeTabs } from 'expo-router/unstable-native-tabs' - -export default function TabLayout() { - return ( - - - Home - - - - Settings - - - - ) -} -``` - -On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the -first `ScrollView` at the root of each tab screen, so content scrolls correctly -behind the translucent tab bar. If you need to disable this, use -`disableAutomaticContentInsets` on the trigger. - -### Prefer Native Header Options Over Custom Components - -**Incorrect (custom header component):** - -```tsx - , - }} -/> -``` - -**Correct (native header options):** - -```tsx - -``` - -Native headers support iOS large titles, search bars, blur effects, and proper -safe area handling automatically. - -### Why Native Navigators - -- **Performance**: Native transitions and gestures run on the UI thread -- **Platform behavior**: Automatic iOS large titles, Android material design -- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe - areas -- **Accessibility**: Platform accessibility features work automatically - -Reference: - -- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator) -- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation) -- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router) -- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs) diff --git a/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md b/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md deleted file mode 100644 index f76c25ac6..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Destructure Functions Early in Render (React Compiler) -impact: HIGH -impactDescription: stable references, fewer re-renders -tags: rerender, hooks, performance, react-compiler ---- - -## Destructure Functions Early in Render - -This rule is only applicable if you are using the React Compiler. - -Destructure functions from hooks at the top of render scope. Never dot into -objects to call functions. Destructured functions are stable references; dotting -creates new references and breaks memoization. - -**Incorrect (dotting into object):** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton(props) { - const router = useRouter() - - // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render - const handlePress = () => { - props.onSave() - router.push('/success') // unstable reference - } - - return -} -``` - -**Correct (destructure early):** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton({ onSave }) { - const { push } = useRouter() - - // good: react-compiler will key on push and onSave - const handlePress = () => { - onSave() - push('/success') // stable reference - } - - return -} -``` diff --git a/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md b/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md deleted file mode 100644 index 0dcbaf47a..000000000 --- a/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Use .get() and .set() for Reanimated Shared Values (not .value) -impact: LOW -impactDescription: required for React Compiler compatibility -tags: reanimated, react-compiler, shared-values ---- - -## Use .get() and .set() for Shared Values with React Compiler - -With React Compiler enabled, use `.get()` and `.set()` instead of reading or -writing `.value` directly on Reanimated shared values. The compiler can't track -property access—explicit methods ensure correct behavior. - -**Incorrect (breaks with React Compiler):** - -```tsx -import { useSharedValue } from 'react-native-reanimated' - -function Counter() { - const count = useSharedValue(0) - - const increment = () => { - count.value = count.value + 1 // opts out of react compiler - } - - return - - ))} - + + + ); } -export default function EventsStreamPage() { +function EventsStreamView() { const { queryOptions, websiteFilters, dateRange, hasQueryId, isLoadingOrg } = useEventsPageContext(); const [page, setPage] = useState(1); - const [allEvents, setAllEvents] = useState([]); - const [loadMoreRef, setLoadMoreRef] = useState( - null - ); - const [scrollContainerRef, setScrollContainerRef] = - useState(null); - const [isInitialLoad, setIsInitialLoad] = useState(true); - - const [searchQuery, setSearchQuery] = useQueryState( - "search", - parseAsString.withDefault("") - ); - const [searchInput, setSearchInput] = useState(searchQuery); - - const debouncedSetSearchQuery = useDebouncedCallback( - (value: string) => { - setSearchQuery(value); - }, - { wait: 300 } - ); - - const handleSearchInputChange = useCallback( - (value: string) => { - setSearchInput(value); - debouncedSetSearchQuery(value); - }, - [debouncedSetSearchQuery] - ); - - const [selectedEventType, setSelectedEventType] = useQueryState( - "event", - parseAsString.withDefault("all") - ); - const [selectedPath, setSelectedPath] = useQueryState( - "path", - parseAsString.withDefault("all") - ); - const [selectedPropertyKey, setSelectedPropertyKey] = useQueryState( - "propKey", - parseAsString.withDefault("all") - ); - const [selectedPropertyValue, setSelectedPropertyValue] = useQueryState( - "propVal", - parseAsString.withDefault("all") - ); - const [hasProperties, setHasProperties] = useQueryState( - "hasProps", - parseAsStringLiteral(["all", "with", "without"] as const).withDefault("all") - ); - - const { events, pagination, isLoading, isError, error } = - useGlobalEventsStream(queryOptions, dateRange, websiteFilters, 50, page, { - enabled: hasQueryId, - }); const eventsKey = useMemo( () => JSON.stringify({ dateRange, queryOptions, websiteFilters }), [dateRange, queryOptions, websiteFilters] ); - const justResetRef = useRef(false); - useEffect(() => { - setPage(1); - setAllEvents([]); - setIsInitialLoad(true); - justResetRef.current = true; - }, [eventsKey]); - - const handleIntersection = useCallback( - (entries: IntersectionObserverEntry[]) => { - const [entry] = entries; - if (entry?.isIntersecting && pagination.hasNext && !isLoading) { - setPage((prev) => prev + 1); - } - }, - [pagination.hasNext, isLoading] - ); - - const observerRef = useRef(null); - useEffect(() => { - if (!(loadMoreRef && scrollContainerRef)) { - return; - } - if (observerRef.current) { - observerRef.current.disconnect(); - } - observerRef.current = new IntersectionObserver(handleIntersection, { - root: scrollContainerRef, - threshold: 0.1, - rootMargin: "300px", - }); - observerRef.current.observe(loadMoreRef); - return () => { - observerRef.current?.disconnect(); - observerRef.current = null; - }; - }, [loadMoreRef, scrollContainerRef, handleIntersection]); - - useEffect(() => { - if (!events?.length) { - return; - } - if (justResetRef.current) { - justResetRef.current = false; - setAllEvents(events); - setIsInitialLoad(false); - return; - } - setAllEvents((prev) => { - const existingKeys = new Set( - prev.map((e) => `${e.timestamp}-${e.event_name}-${e.session_id}`) - ); - let hasNewEvents = false; - const newEvents = [...prev]; - for (const event of events) { - const key = `${event.timestamp}-${event.event_name}-${event.session_id}`; - if (!existingKeys.has(key)) { - newEvents.push(event); - hasNewEvents = true; - } - } - return hasNewEvents ? newEvents : prev; + const { events, pagination, isLoading, isError, error } = + useGlobalEventsStream(queryOptions, dateRange, websiteFilters, 50, page, { + enabled: hasQueryId, }); - setIsInitialLoad(false); - }, [events]); - - const eventTypes = useMemo(() => { - const types = new Set(allEvents.map((e) => e.event_name)); - return Array.from(types).sort(); - }, [allEvents]); - - const uniquePaths = useMemo(() => { - const paths = new Set(allEvents.map((e) => e.path).filter(Boolean)); - return Array.from(paths).sort() as string[]; - }, [allEvents]); - - const uniquePropertyKeys = useMemo(() => { - const keys = new Set(); - for (const event of allEvents) { - for (const key of Object.keys(event.properties)) { - keys.add(key); - } - } - return Array.from(keys).sort(); - }, [allEvents]); - - const propertyValues = useMemo(() => { - if (selectedPropertyKey === "all") { - return []; - } - - const values = new Set(); - for (const event of allEvents) { - const val = event.properties[selectedPropertyKey]; - if (val !== undefined && val !== null) { - values.add(String(val)); - } - } - return Array.from(values).sort(); - }, [allEvents, selectedPropertyKey]); - - const filteredEvents = useMemo(() => { - let result = allEvents; - - if (selectedEventType !== "all") { - result = result.filter((e) => e.event_name === selectedEventType); - } - if (selectedPath !== "all") { - result = result.filter((e) => e.path === selectedPath); - } - - if (hasProperties === "with") { - result = result.filter((e) => Object.keys(e.properties).length > 0); - } else if (hasProperties === "without") { - result = result.filter((e) => Object.keys(e.properties).length === 0); - } - - if (selectedPropertyKey !== "all") { - result = result.filter((e) => selectedPropertyKey in e.properties); - - if (selectedPropertyValue !== "all") { - result = result.filter( - (e) => - String(e.properties[selectedPropertyKey]) === selectedPropertyValue - ); - } - } - - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (e) => - e.event_name.toLowerCase().includes(query) || - e.path?.toLowerCase().includes(query) || - Object.values(e.properties).some((val) => - String(val).toLowerCase().includes(query) - ) - ); - } - - return result; - }, [ - allEvents, - selectedEventType, - selectedPath, - hasProperties, - selectedPropertyKey, - selectedPropertyValue, - searchQuery, - ]); - - const activeFilters = useMemo(() => { - const result: ActiveFilter[] = []; - - if (selectedEventType !== "all") { - result.push({ - type: "event", - label: "Event", - value: selectedEventType, - onRemoveAction: () => setSelectedEventType("all"), - }); - } - - if (selectedPath !== "all") { - result.push({ - type: "path", - label: "Path", - value: selectedPath, - onRemoveAction: () => setSelectedPath("all"), - }); - } - - if (hasProperties !== "all") { - result.push({ - type: "hasProps", - label: "Properties", - value: hasProperties === "with" ? "With" : "Without", - onRemoveAction: () => setHasProperties("all"), - }); - } - - if (selectedPropertyKey !== "all") { - const propLabel = - selectedPropertyValue === "all" - ? selectedPropertyKey - : `${selectedPropertyKey} = ${selectedPropertyValue}`; - result.push({ - type: "property", - label: "Property", - value: propLabel, - onRemoveAction: () => { - setSelectedPropertyKey("all"); - setSelectedPropertyValue("all"); - }, - }); - } - - return result; - }, [ - selectedEventType, - selectedPath, - hasProperties, - selectedPropertyKey, - selectedPropertyValue, - setSelectedEventType, - setSelectedPath, - setHasProperties, - setSelectedPropertyKey, - setSelectedPropertyValue, - ]); - - const clearAllFilters = useCallback(() => { - setSelectedEventType("all"); - setSelectedPath("all"); - setHasProperties("all"); - setSelectedPropertyKey("all"); - setSelectedPropertyValue("all"); - setSearchQuery(""); - setSearchInput(""); - }, [ - setSelectedEventType, - setSelectedPath, - setHasProperties, - setSelectedPropertyKey, - setSelectedPropertyValue, - setSearchQuery, - ]); - - const handleCopyEvent = useCallback((event: RecentCustomEvent) => { - const data = { - event_name: event.event_name, - path: event.path, - timestamp: event.timestamp, - properties: event.properties, - }; - navigator.clipboard.writeText(JSON.stringify(data, null, 2)); - }, []); - - const handlePropertyKeyChange = useCallback( - (value: string) => { - setSelectedPropertyKey(value); - setSelectedPropertyValue("all"); - }, - [setSelectedPropertyKey, setSelectedPropertyValue] - ); - - const columns = useMemo[]>( - () => [ - { - id: "timestamp", - header: "Time", - accessorFn: (row) => row.timestamp, - cell: ({ row }) => ( -
- - {fromNow(row.original.timestamp)} - - - {formatTime(row.original.timestamp)} - -
- ), - size: 100, - }, - { - id: "event_name", - header: "Event", - accessorFn: (row) => row.event_name, - cell: ({ row }) => ( -
- - {row.original.event_name} - -
- ), - size: 160, - }, - { - id: "path", - header: "Page", - accessorFn: (row) => row.path, - cell: ({ row }) => - row.original.path ? ( - - ) : ( - - ), - size: 200, - }, - { - id: "properties", - header: "Properties", - cell: ({ row }) => { - const props = row.original.properties; - const entries = Object.entries(props); - - if (entries.length === 0) { - return ( - - No properties - - ); - } - - return ( -
- {entries.slice(0, 3).map(([key, value]) => { - const strValue = String(value); - const isLong = strValue.length > 30; - return ( - - ); - })} - {entries.length > 3 && ( - - +{entries.length - 3} more - - )} -
- ); - }, - size: 300, - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( - - ), - size: 40, - }, - ], - [ - handleCopyEvent, - setSelectedPath, - setSelectedPropertyKey, - setSelectedPropertyValue, - ] - ); - - const table = useReactTable({ - data: filteredEvents, - columns, - getCoreRowModel: getCoreRowModel(), - getRowId: (row, index) => `${index}-${row.timestamp}-${row.event_name}`, - }); - - const isPageLoading = isLoadingOrg || (isLoading && isInitialLoad); - - if (isPageLoading) { + const renderEventName = useCallback((event: RecentCustomEvent) => { return ( -
-
-
- - -
- - -
- - - -
- -
- -
-
-
- - - - {columns.map((column) => ( - - {typeof column.header === "string" ? column.header : null} - - ))} - - - - {Array.from({ length: 10 }).map((_, i) => ( - - ))} - -
-
+
+ + {event.event_name} +
); - } - - if (isError) { - return ( -
- } - title="Failed to load events" - variant="error" - /> -
- ); - } - - if (!allEvents || allEvents.length === 0) { - return ( -
- - Events will appear here once your tracker starts collecting them. - Use{" "} - - databuddy.track() - {" "} - to send custom events. - - } - icon={} - title="No events yet" - variant="minimal" - /> -
- ); - } - - const hasActiveFilters = activeFilters.length > 0 || searchQuery.trim(); + }, []); return ( -
-
-
-
- - -
- -
- -
- - -
- -
- -
- - - - {uniquePropertyKeys.length > 0 && ( - - )} - - {selectedPropertyKey !== "all" && propertyValues.length > 0 && ( - <> - = - - - )} -
- -
- -
- - handleSearchInputChange(e.target.value)} - placeholder="Search…" - value={searchInput} - /> -
- -
- -
- - {filteredEvents.length.toLocaleString()} event - {filteredEvents.length === 1 ? "" : "s"} - - - {hasActiveFilters && ( - - )} -
-
- - {activeFilters.length > 0 && } -
- -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - - ))} - - - {filteredEvents.length === 0 ? ( - - - No events match your filters - - - ) : ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - )) - )} - - {pagination.hasNext && filteredEvents.length > 0 && ( - <> - - - - {isLoading && ( - <> - - - - - )} - - )} - -
-
-
+ ); } diff --git a/apps/dashboard/app/(main)/events/stream/use-global-events-stream.ts b/apps/dashboard/app/(main)/events/stream/use-global-events-stream.ts index f5b483b9c..b88afc987 100644 --- a/apps/dashboard/app/(main)/events/stream/use-global-events-stream.ts +++ b/apps/dashboard/app/(main)/events/stream/use-global-events-stream.ts @@ -12,8 +12,8 @@ import type { } from "../_components/types"; interface QueryOptions { - websiteId?: string; organizationId?: string; + websiteId?: string; } function parseEventProperties( diff --git a/apps/dashboard/app/(main)/feedback/components/feedback-credits-card.tsx b/apps/dashboard/app/(main)/feedback/components/feedback-credits-card.tsx index 33eb46790..fdfa934cd 100644 --- a/apps/dashboard/app/(main)/feedback/components/feedback-credits-card.tsx +++ b/apps/dashboard/app/(main)/feedback/components/feedback-credits-card.tsx @@ -1,13 +1,15 @@ "use client"; -import { CoinIcon, ShoppingCartIcon, TrendUpIcon } from "@phosphor-icons/react"; +import { CoinIcon } from "@phosphor-icons/react"; +import { ShoppingCartIcon } from "@phosphor-icons/react"; +import { TrendUpIcon } from "@phosphor-icons/react"; import { Skeleton } from "@/components/ui/skeleton"; interface FeedbackCreditsCardProps { available: number; + isLoading: boolean; totalEarned: number; totalSpent: number; - isLoading: boolean; } export function FeedbackCreditsCard({ diff --git a/apps/dashboard/app/(main)/feedback/components/redeem-dialog.tsx b/apps/dashboard/app/(main)/feedback/components/redeem-dialog.tsx index 475e4aaec..8193763a8 100644 --- a/apps/dashboard/app/(main)/feedback/components/redeem-dialog.tsx +++ b/apps/dashboard/app/(main)/feedback/components/redeem-dialog.tsx @@ -15,12 +15,12 @@ import { import { orpc } from "@/lib/orpc"; interface RedeemDialogProps { - open: boolean; - onOpenChangeAction: (open: boolean) => void; - tierIndex: number; creditsRequired: number; + onOpenChangeAction: (open: boolean) => void; + open: boolean; rewardAmount: number; rewardType: string; + tierIndex: number; } export function RedeemDialog({ diff --git a/apps/dashboard/app/(main)/feedback/components/shop-reward-card.tsx b/apps/dashboard/app/(main)/feedback/components/shop-reward-card.tsx index 770e22561..1ad448532 100644 --- a/apps/dashboard/app/(main)/feedback/components/shop-reward-card.tsx +++ b/apps/dashboard/app/(main)/feedback/components/shop-reward-card.tsx @@ -5,12 +5,12 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface ShopRewardCardProps { + availableCredits: number; creditsRequired: number; + isRedeeming: boolean; + onRedeemAction: () => void; rewardAmount: number; rewardType: string; - availableCredits: number; - onRedeemAction: () => void; - isRedeeming: boolean; } export function ShopRewardCard({ diff --git a/apps/dashboard/app/(main)/feedback/components/submit-feedback-dialog.tsx b/apps/dashboard/app/(main)/feedback/components/submit-feedback-dialog.tsx index 213750c09..20d7d3a8d 100644 --- a/apps/dashboard/app/(main)/feedback/components/submit-feedback-dialog.tsx +++ b/apps/dashboard/app/(main)/feedback/components/submit-feedback-dialog.tsx @@ -75,7 +75,9 @@ export function SubmitFeedbackDialog() { !submitMutation.isPending; const handleSubmitAction = () => { - if (!canSubmit) return; + if (!canSubmit) { + return; + } submitMutation.mutate({ title: title.trim(), description: description.trim(), diff --git a/apps/dashboard/app/(main)/feedback/page.tsx b/apps/dashboard/app/(main)/feedback/page.tsx index 715e0080b..6f92b1a3b 100644 --- a/apps/dashboard/app/(main)/feedback/page.tsx +++ b/apps/dashboard/app/(main)/feedback/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { ChatTextIcon, ShoppingCartIcon } from "@phosphor-icons/react"; +import { ChatTextIcon } from "@phosphor-icons/react"; +import { ShoppingCartIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -88,7 +89,9 @@ export default function FeedbackPage() { { - if (!open) setRedeemTier(null); + if (!open) { + setRedeemTier(null); + } }} open rewardAmount={REWARD_TIERS[redeemTier].rewardAmount} diff --git a/apps/dashboard/app/(main)/home/_components/monitors-section.tsx b/apps/dashboard/app/(main)/home/_components/monitors-section.tsx index 3a058344f..7596638c3 100644 --- a/apps/dashboard/app/(main)/home/_components/monitors-section.tsx +++ b/apps/dashboard/app/(main)/home/_components/monitors-section.tsx @@ -1,6 +1,8 @@ "use client"; -import { HeartbeatIcon, LockIcon, PlusIcon } from "@phosphor-icons/react"; +import { HeartbeatIcon } from "@phosphor-icons/react"; +import { LockIcon } from "@phosphor-icons/react"; +import { PlusIcon } from "@phosphor-icons/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo } from "react"; @@ -15,6 +17,9 @@ import { UptimeHeatmapStrip } from "@/lib/uptime/heatmap-strip"; import { cn } from "@/lib/utils"; interface MonitorsSectionProps { + activeMonitors: number; + hasAccess: boolean; + isLoading: boolean; monitors: Array<{ id: string; name: string | null; @@ -23,11 +28,8 @@ interface MonitorsSectionProps { isPaused: boolean; granularity: string; }>; - totalMonitors: number; - activeMonitors: number; - hasAccess: boolean; - isLoading: boolean; onCreateMonitorAction?: () => void; + totalMonitors: number; } function HomeMonitorHeatmap({ diff --git a/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx b/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx index 0934595ec..06a8d415d 100644 --- a/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx +++ b/apps/dashboard/app/(main)/home/_components/smart-insights-section.tsx @@ -1,19 +1,17 @@ "use client"; -import { - ArrowClockwiseIcon, - ArrowRightIcon, - BugIcon, - CaretDownIcon, - CheckCircleIcon, - GaugeIcon, - LightningIcon, - RocketIcon, - SparkleIcon, - TrendDownIcon, - TrendUpIcon, - WarningCircleIcon, -} from "@phosphor-icons/react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { BugIcon } from "@phosphor-icons/react"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { GaugeIcon } from "@phosphor-icons/react"; +import { LightningIcon } from "@phosphor-icons/react"; +import { RocketIcon } from "@phosphor-icons/react"; +import { SparkleIcon } from "@phosphor-icons/react"; +import { TrendDownIcon } from "@phosphor-icons/react"; +import { TrendUpIcon } from "@phosphor-icons/react"; +import { WarningCircleIcon } from "@phosphor-icons/react"; import Link from "next/link"; import { type ReactNode, useMemo, useState } from "react"; import { InsightMetrics } from "@/components/insight-metrics"; @@ -299,10 +297,10 @@ function ErrorState({ onRetryAction }: { onRetryAction?: () => void }) { interface InsightsSectionProps { insights: Insight[]; - isLoading?: boolean; + isError?: boolean; isFetching?: boolean; isFetchingFresh?: boolean; - isError?: boolean; + isLoading?: boolean; onRefreshAction?: () => void; /** `compact` = capped list height (home). `full` = grows with parent flex layout (`/insights`). */ variant?: "compact" | "full"; diff --git a/apps/dashboard/app/(main)/home/_components/summary-stats.tsx b/apps/dashboard/app/(main)/home/_components/summary-stats.tsx index 948e1850d..970de35b6 100644 --- a/apps/dashboard/app/(main)/home/_components/summary-stats.tsx +++ b/apps/dashboard/app/(main)/home/_components/summary-stats.tsx @@ -1,41 +1,30 @@ "use client"; -import { - EyeIcon, - HeartbeatIcon, - LockIcon, - MinusIcon, - TrendDownIcon, - TrendUpIcon, - UsersIcon, -} from "@phosphor-icons/react"; +import { EyeIcon } from "@phosphor-icons/react"; +import { HeartbeatIcon } from "@phosphor-icons/react"; +import { LockIcon } from "@phosphor-icons/react"; +import { MinusIcon } from "@phosphor-icons/react"; +import { TrendDownIcon } from "@phosphor-icons/react"; +import { TrendUpIcon } from "@phosphor-icons/react"; +import { UsersIcon } from "@phosphor-icons/react"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { formatNumber } from "@/lib/formatters"; import { cn } from "@/lib/utils"; interface SummaryStatsProps { + activeMonitors: number; + averageTrend: number; + hasPulseAccess: boolean; + isLoading?: boolean; + pulseHealthPercentage: number; totalActiveUsers: number; + totalMonitors: number; totalViews: number; - averageTrend: number; trendDirection: "up" | "down" | "neutral"; websiteCount: number; - pulseHealthPercentage: number; - totalMonitors: number; - activeMonitors: number; - hasPulseAccess: boolean; - isLoading?: boolean; -} - -function formatNumber(num: number) { - if (num >= 1_000_000) { - return `${(num / 1_000_000).toFixed(1)}M`; - } - if (num >= 1000) { - return `${(num / 1000).toFixed(1)}K`; - } - return num.toString(); } function StatCardSkeleton() { diff --git a/apps/dashboard/app/(main)/home/hooks/use-global-analytics.ts b/apps/dashboard/app/(main)/home/hooks/use-global-analytics.ts index 5a05110dc..644fd1cc4 100644 --- a/apps/dashboard/app/(main)/home/hooks/use-global-analytics.ts +++ b/apps/dashboard/app/(main)/home/hooks/use-global-analytics.ts @@ -6,24 +6,24 @@ import type { Website } from "@/hooks/use-websites"; import { useWebsites } from "@/hooks/use-websites"; export interface GlobalAnalytics { - totalActiveUsers: number; - totalViews: number; averageTrend: number; - trendDirection: "up" | "down" | "neutral"; - websiteCount: number; - topPerformers: Array<{ + needsSetup: Array<{ id: string; name: string | null; domain: string; - views: number; - trend: ProcessedMiniChartData["trend"]; - activeUsers: number; }>; - needsSetup: Array<{ + topPerformers: Array<{ id: string; name: string | null; domain: string; + views: number; + trend: ProcessedMiniChartData["trend"]; + activeUsers: number; }>; + totalActiveUsers: number; + totalViews: number; + trendDirection: "up" | "down" | "neutral"; + websiteCount: number; } export function useGlobalAnalytics() { diff --git a/apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts b/apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts index 1d4c94547..f1089154a 100644 --- a/apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts +++ b/apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts @@ -6,9 +6,7 @@ import { useFeatureAccess } from "@/hooks/use-feature-access"; import { orpc } from "@/lib/orpc"; export interface PulseStatus { - totalMonitors: number; activeMonitors: number; - pausedMonitors: number; healthPercentage: number; monitors: Array<{ id: string; @@ -18,6 +16,8 @@ export interface PulseStatus { isPaused: boolean; granularity: string; }>; + pausedMonitors: number; + totalMonitors: number; } export function usePulseStatus() { diff --git a/apps/dashboard/app/(main)/home/page.tsx b/apps/dashboard/app/(main)/home/page.tsx index 210b0ea51..87e56874c 100644 --- a/apps/dashboard/app/(main)/home/page.tsx +++ b/apps/dashboard/app/(main)/home/page.tsx @@ -1,11 +1,9 @@ "use client"; -import { - ArrowClockwiseIcon, - GlobeIcon, - HouseIcon, - PlusIcon, -} from "@phosphor-icons/react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { GlobeIcon } from "@phosphor-icons/react"; +import { HouseIcon } from "@phosphor-icons/react"; +import { PlusIcon } from "@phosphor-icons/react"; import Link from "next/link"; import { useState } from "react"; import { EmptyState } from "@/components/empty-state"; diff --git a/apps/dashboard/app/(main)/insights/_components/insight-card.tsx b/apps/dashboard/app/(main)/insights/_components/insight-card.tsx index 6cf326c24..dff8b32c8 100644 --- a/apps/dashboard/app/(main)/insights/_components/insight-card.tsx +++ b/apps/dashboard/app/(main)/insights/_components/insight-card.tsx @@ -1,23 +1,21 @@ "use client"; -import { - ArrowRightIcon, - BugIcon, - CaretDownIcon, - ChartLineUpIcon, - CopyIcon, - DotsThreeIcon, - GaugeIcon, - LightningIcon, - LinkIcon, - RocketIcon, - ThumbsDownIcon, - ThumbsUpIcon, - TrendDownIcon, - TrendUpIcon, - WarningCircleIcon, - XIcon, -} from "@phosphor-icons/react"; +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { BugIcon } from "@phosphor-icons/react"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { ChartLineUpIcon } from "@phosphor-icons/react"; +import { CopyIcon } from "@phosphor-icons/react"; +import { DotsThreeIcon } from "@phosphor-icons/react"; +import { GaugeIcon } from "@phosphor-icons/react"; +import { LightningIcon } from "@phosphor-icons/react"; +import { LinkIcon } from "@phosphor-icons/react"; +import { RocketIcon } from "@phosphor-icons/react"; +import { ThumbsDownIcon } from "@phosphor-icons/react"; +import { ThumbsUpIcon } from "@phosphor-icons/react"; +import { TrendDownIcon } from "@phosphor-icons/react"; +import { TrendUpIcon } from "@phosphor-icons/react"; +import { WarningCircleIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; import Link from "next/link"; import { type ReactNode, useMemo } from "react"; import { toast } from "sonner"; @@ -162,12 +160,12 @@ function buildDiagnosticPrompt(insight: Insight): string { } export interface InsightCardProps { - insight: Insight; expanded: boolean; - onToggleAction: () => void; - onDismissAction?: () => void; feedbackVote?: InsightFeedbackVote | null; + insight: Insight; + onDismissAction?: () => void; onFeedbackAction?: (vote: InsightFeedbackVote | null) => void; + onToggleAction: () => void; } export function InsightCard({ diff --git a/apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx b/apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx index 88eab31a9..5ea00ad3c 100644 --- a/apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx +++ b/apps/dashboard/app/(main)/insights/_components/insights-page-content.tsx @@ -1,19 +1,5 @@ "use client"; -import { - ArrowClockwiseIcon, - ArrowsDownUpIcon, - CaretDownIcon, - CheckCircleIcon, - FunnelIcon, - SparkleIcon, - TrashIcon, - WarningCircleIcon, - XIcon, -} from "@phosphor-icons/react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; import { useInsightsFeed } from "@/app/(main)/insights/hooks/use-insights-feed"; import { useInsightsLocalState } from "@/app/(main)/insights/hooks/use-insights-local-state"; import { PageHeader } from "@/app/(main)/websites/_components/page-header"; @@ -45,6 +31,18 @@ import { import type { Insight, InsightSeverity } from "@/lib/insight-types"; import { orpc } from "@/lib/orpc"; import { cn } from "@/lib/utils"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { ArrowsDownUpIcon } from "@phosphor-icons/react"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { FunnelIcon } from "@phosphor-icons/react"; +import { SparkleIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; +import { WarningCircleIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import { InsightCard } from "./insight-card"; type SeverityFilter = "all" | InsightSeverity; diff --git a/apps/dashboard/app/(main)/invitations/[id]/page.tsx b/apps/dashboard/app/(main)/invitations/[id]/page.tsx index 6aeb6ce26..145c7e225 100644 --- a/apps/dashboard/app/(main)/invitations/[id]/page.tsx +++ b/apps/dashboard/app/(main)/invitations/[id]/page.tsx @@ -1,15 +1,13 @@ "use client"; import { authClient } from "@databuddy/auth/client"; -import { - ArrowRightIcon, - BuildingsIcon, - CheckCircleIcon, - ClockIcon, - SpinnerGapIcon, - UserPlusIcon, - XCircleIcon, -} from "@phosphor-icons/react"; +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { BuildingsIcon } from "@phosphor-icons/react"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { ClockIcon } from "@phosphor-icons/react"; +import { SpinnerGapIcon } from "@phosphor-icons/react"; +import { UserPlusIcon } from "@phosphor-icons/react"; +import { XCircleIcon } from "@phosphor-icons/react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useState } from "react"; @@ -23,16 +21,16 @@ import { cn } from "@/lib/utils"; import { PageHeader } from "../../websites/_components/page-header"; interface InvitationData { - organizationName: string; - organizationSlug: string; - inviterEmail: string; - id: string; email: string; - status: "pending" | "accepted" | "rejected" | "canceled"; expiresAt: Date; + id: string; + inviterEmail: string; + inviterId: string; organizationId: string; + organizationName: string; + organizationSlug: string; role: string; - inviterId: string; + status: "pending" | "accepted" | "rejected" | "canceled"; teamId?: string; } diff --git a/apps/dashboard/app/(main)/invite/[token]/page.tsx b/apps/dashboard/app/(main)/invite/[token]/page.tsx index 5b2fe6f5d..1be7e4bee 100644 --- a/apps/dashboard/app/(main)/invite/[token]/page.tsx +++ b/apps/dashboard/app/(main)/invite/[token]/page.tsx @@ -1,19 +1,17 @@ "use client"; import type { IconProps } from "@phosphor-icons/react"; -import { - ArrowRightIcon, - CheckCircleIcon, - HeartbeatIcon, - LightbulbFilamentIcon, - LightningIcon, - ProhibitIcon, - RobotIcon, - SpinnerGapIcon, - TrendUpIcon, - WaveformIcon, - XCircleIcon, -} from "@phosphor-icons/react"; +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { HeartbeatIcon } from "@phosphor-icons/react"; +import { LightbulbFilamentIcon } from "@phosphor-icons/react"; +import { LightningIcon } from "@phosphor-icons/react"; +import { ProhibitIcon } from "@phosphor-icons/react"; +import { RobotIcon } from "@phosphor-icons/react"; +import { SpinnerGapIcon } from "@phosphor-icons/react"; +import { TrendUpIcon } from "@phosphor-icons/react"; +import { WaveformIcon } from "@phosphor-icons/react"; +import { XCircleIcon } from "@phosphor-icons/react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; import type { ForwardRefExoticComponent, RefAttributes } from "react"; diff --git a/apps/dashboard/app/(main)/layout.tsx b/apps/dashboard/app/(main)/layout.tsx index 717c751c1..88d668058 100644 --- a/apps/dashboard/app/(main)/layout.tsx +++ b/apps/dashboard/app/(main)/layout.tsx @@ -1,11 +1,10 @@ -import { AutumnProvider } from "autumn-js/react"; -import { Suspense } from "react"; import { FeedbackPrompt } from "@/components/feedback-prompt"; import { Sidebar } from "@/components/layout/sidebar"; +import { SidebarNavigationProvider } from "@/components/layout/sidebar-navigation-provider"; import { BillingProvider } from "@/components/providers/billing-provider"; import { CommandSearchProvider } from "@/components/ui/command-search"; - -export const dynamic = "force-dynamic"; +import { AutumnProvider } from "autumn-js/react"; +import { Suspense } from "react"; export default function MainLayout({ children, @@ -19,17 +18,19 @@ export default function MainLayout({ > -
- - - -
-
- {children} + +
+ + + +
+
+ {children} +
+
- -
+ diff --git a/apps/dashboard/app/(main)/links/[id]/_components/clicks-chart.tsx b/apps/dashboard/app/(main)/links/[id]/_components/clicks-chart.tsx index fad080553..ccc6892c8 100644 --- a/apps/dashboard/app/(main)/links/[id]/_components/clicks-chart.tsx +++ b/apps/dashboard/app/(main)/links/[id]/_components/clicks-chart.tsx @@ -1,12 +1,12 @@ "use client"; -import { ChartLineIcon } from "@phosphor-icons/react/dist/ssr/ChartLine"; +import { ChartLineIcon } from "@phosphor-icons/react"; import { Chart } from "@/components/ui/composables/chart"; import dayjs from "@/lib/dayjs"; export interface ChartDataPoint { - date: string; clicks: number; + date: string; } interface ClicksChartProps { @@ -28,10 +28,7 @@ export function ClicksChart({ >
- +

No click data available diff --git a/apps/dashboard/app/(main)/links/[id]/_components/link-stats-columns.tsx b/apps/dashboard/app/(main)/links/[id]/_components/link-stats-columns.tsx index 88f2c53e0..aff61a283 100644 --- a/apps/dashboard/app/(main)/links/[id]/_components/link-stats-columns.tsx +++ b/apps/dashboard/app/(main)/links/[id]/_components/link-stats-columns.tsx @@ -1,25 +1,26 @@ "use client"; -import { MapPinIcon } from "@phosphor-icons/react/dist/ssr/MapPin"; +import { MapPinIcon } from "@phosphor-icons/react"; import type { CellContext, ColumnDef } from "@tanstack/react-table"; import { DeviceTypeCell } from "@/components/analytics"; import { ReferrerSourceCell } from "@/components/atomic/ReferrerSourceCell"; import { CountryFlag } from "@/components/icon"; import { PercentageBadge } from "@/components/ui/percentage-badge"; +import { formatNumber } from "@/lib/formatters"; export interface SourceEntry { - name: string; clicks: number; + domain?: string; + name: string; percentage: number; referrer?: string; - domain?: string; } export interface GeoEntry { - name: string; + clicks: number; country_code: string; country_name: string; - clicks: number; + name: string; percentage: number; } @@ -41,16 +42,6 @@ function extractDomain(referrer: string | undefined): string | undefined { } } -function formatNumber(value: number): string { - if (value == null || Number.isNaN(value)) { - return "0"; - } - return Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); -} - export function createReferrerColumns(): ColumnDef[] { return [ { diff --git a/apps/dashboard/app/(main)/links/[id]/_components/link-stats-content.tsx b/apps/dashboard/app/(main)/links/[id]/_components/link-stats-content.tsx index 3da1196c1..52efeaf79 100644 --- a/apps/dashboard/app/(main)/links/[id]/_components/link-stats-content.tsx +++ b/apps/dashboard/app/(main)/links/[id]/_components/link-stats-content.tsx @@ -1,9 +1,9 @@ "use client"; -import { CursorClickIcon } from "@phosphor-icons/react/dist/ssr/CursorClick"; -import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe"; -import { LinkIcon } from "@phosphor-icons/react/dist/ssr/Link"; -import { UsersIcon } from "@phosphor-icons/react/dist/ssr/Users"; +import { CursorClickIcon } from "@phosphor-icons/react"; +import { GlobeIcon } from "@phosphor-icons/react"; +import { LinkIcon } from "@phosphor-icons/react"; +import { UsersIcon } from "@phosphor-icons/react"; import { useParams, useRouter } from "next/navigation"; import { useMemo } from "react"; import { StatCard } from "@/components/analytics"; @@ -14,7 +14,7 @@ import { useDateFilters } from "@/hooks/use-date-filters"; import { useLink, useLinkStats } from "@/hooks/use-links"; import { useMediaQuery } from "@/hooks/use-media-query"; import dayjs from "@/lib/dayjs"; -import { formatMetricNumber } from "@/lib/formatters"; +import { formatNumber } from "@/lib/formatters"; import { type ChartDataPoint, ClicksChart } from "./clicks-chart"; import { createDeviceColumns, @@ -29,16 +29,6 @@ interface MiniChartDataPoint { value: number; } -function formatNumber(value: number): string { - if (value == null || Number.isNaN(value)) { - return "0"; - } - return Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); -} - function StatsLoadingSkeleton() { return (

@@ -251,7 +241,7 @@ export function LinkStatsContent() { isLoading={isLoading} showChart={true} title="Total Clicks" - value={formatMetricNumber(stats?.totalClicks ?? 0)} + value={formatNumber(stats?.totalClicks ?? 0)} /> ; - title: string; badge?: number | boolean; + children: React.ReactNode; + icon: React.ComponentType<{ size?: number; weight?: "duotone" | "fill" }>; isExpanded: boolean; onToggleAction: () => void; - children: React.ReactNode; + title: string; } export function CollapsibleSection({ diff --git a/apps/dashboard/app/(main)/links/_components/expiration-picker.tsx b/apps/dashboard/app/(main)/links/_components/expiration-picker.tsx index ec64ffcc5..7a137b46e 100644 --- a/apps/dashboard/app/(main)/links/_components/expiration-picker.tsx +++ b/apps/dashboard/app/(main)/links/_components/expiration-picker.tsx @@ -1,12 +1,10 @@ "use client"; -import { - CalendarIcon, - CheckIcon, - ClockIcon, - InfinityIcon, - XIcon, -} from "@phosphor-icons/react"; +import { CalendarIcon } from "@phosphor-icons/react"; +import { CheckIcon } from "@phosphor-icons/react"; +import { ClockIcon } from "@phosphor-icons/react"; +import { InfinityIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; import { useCallback, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; @@ -19,9 +17,9 @@ import dayjs from "@/lib/dayjs"; import { cn } from "@/lib/utils"; interface ExpirationPreset { + getDate: () => Date; label: string; value: string; - getDate: () => Date; } const EXPIRATION_PRESETS: ExpirationPreset[] = [ @@ -73,9 +71,9 @@ function formatPresetPreview(preset: ExpirationPreset): string { } interface ExpirationPickerProps { - value?: string; - onChange: (value: string) => void; className?: string; + onChange: (value: string) => void; + value?: string; } export function ExpirationPicker({ diff --git a/apps/dashboard/app/(main)/links/_components/link-form-fields.tsx b/apps/dashboard/app/(main)/links/_components/link-form-fields.tsx index 76e9e8198..6b6931bb8 100644 --- a/apps/dashboard/app/(main)/links/_components/link-form-fields.tsx +++ b/apps/dashboard/app/(main)/links/_components/link-form-fields.tsx @@ -1,13 +1,11 @@ "use client"; -import { - AndroidLogoIcon, - AppleLogoIcon, - CalendarIcon, - DeviceMobileIcon, - ImageIcon, - LinkSimpleIcon, -} from "@phosphor-icons/react"; +import { AndroidLogoIcon } from "@phosphor-icons/react"; +import { AppleLogoIcon } from "@phosphor-icons/react"; +import { CalendarIcon } from "@phosphor-icons/react"; +import { DeviceMobileIcon } from "@phosphor-icons/react"; +import { ImageIcon } from "@phosphor-icons/react"; +import { LinkSimpleIcon } from "@phosphor-icons/react"; import type { UseFormReturn } from "react-hook-form"; import { FormControl, @@ -29,22 +27,22 @@ import { type OgData, OgPreview } from "./og-preview"; import { UtmBuilder, type UtmParams } from "./utm-builder"; interface LinkFormFieldsProps { - form: UseFormReturn; - isEditMode: boolean; + deviceTargetingCount: number; expandedSection: ExpandedSection; - onToggleSectionAction: (section: ExpandedSection) => void; - slugValue: string | undefined; + form: UseFormReturn; fullTargetUrl: string; - utmParams: UtmParams; - onUtmParamsChangeAction: (params: UtmParams) => void; + hasCustomSocial: boolean; + hasExpiration: boolean; + isEditMode: boolean; ogData: OgData; onOgDataChangeAction: (data: OgData) => void; - useCustomOg: boolean; + onToggleSectionAction: (section: ExpandedSection) => void; onUseCustomOgChangeAction: (useCustom: boolean) => void; - hasExpiration: boolean; - deviceTargetingCount: number; + onUtmParamsChangeAction: (params: UtmParams) => void; + slugValue: string | undefined; + useCustomOg: boolean; + utmParams: UtmParams; utmParamsCount: number; - hasCustomSocial: boolean; } export function LinkFormFields({ diff --git a/apps/dashboard/app/(main)/links/_components/link-item.tsx b/apps/dashboard/app/(main)/links/_components/link-item.tsx index d481b18a6..2ddff2adc 100644 --- a/apps/dashboard/app/(main)/links/_components/link-item.tsx +++ b/apps/dashboard/app/(main)/links/_components/link-item.tsx @@ -1,14 +1,12 @@ "use client"; -import { - ClockCountdownIcon, - CopyIcon, - DotsThreeIcon, - PencilSimpleIcon, - QrCodeIcon, - TrashIcon, -} from "@phosphor-icons/react"; -import { LinkIcon } from "@phosphor-icons/react/dist/ssr/Link"; +import { ClockCountdownIcon } from "@phosphor-icons/react"; +import { CopyIcon } from "@phosphor-icons/react"; +import { DotsThreeIcon } from "@phosphor-icons/react"; +import { PencilSimpleIcon } from "@phosphor-icons/react"; +import { QrCodeIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; +import { LinkIcon } from "@phosphor-icons/react"; import NextLink from "next/link"; import { toast } from "sonner"; import { FaviconImage } from "@/components/analytics/favicon-image"; @@ -220,10 +218,10 @@ function LinkRow({ interface LinksListProps { links: Link[]; - onEdit: (link: Link) => void; + onCreateLink: () => void; onDelete: (linkId: string) => void; + onEdit: (link: Link) => void; onShowQr: (link: Link) => void; - onCreateLink: () => void; } export function LinksList({ diff --git a/apps/dashboard/app/(main)/links/_components/link-qr-code.tsx b/apps/dashboard/app/(main)/links/_components/link-qr-code.tsx index 1d7579c41..d6d3f3e54 100644 --- a/apps/dashboard/app/(main)/links/_components/link-qr-code.tsx +++ b/apps/dashboard/app/(main)/links/_components/link-qr-code.tsx @@ -1,11 +1,9 @@ "use client"; -import { - CopyIcon, - DownloadSimpleIcon, - ImageIcon, - XIcon, -} from "@phosphor-icons/react"; +import { CopyIcon } from "@phosphor-icons/react"; +import { DownloadSimpleIcon } from "@phosphor-icons/react"; +import { ImageIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; import { useCallback, useRef, useState } from "react"; import { QRCode } from "react-qrcode-logo"; import { toast } from "sonner"; @@ -34,10 +32,10 @@ const QR_COLORS = [ ]; interface LinkQrCodeProps { - slug: string; + className?: string; name: string; showControls?: boolean; - className?: string; + slug: string; } export function LinkQrCode({ diff --git a/apps/dashboard/app/(main)/links/_components/link-sheet.tsx b/apps/dashboard/app/(main)/links/_components/link-sheet.tsx index ae85858fa..c8263419f 100644 --- a/apps/dashboard/app/(main)/links/_components/link-sheet.tsx +++ b/apps/dashboard/app/(main)/links/_components/link-sheet.tsx @@ -1,12 +1,10 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { - CircleNotchIcon, - CopyIcon, - LinkSimpleIcon, - QrCodeIcon, -} from "@phosphor-icons/react"; +import { CircleNotchIcon } from "@phosphor-icons/react"; +import { CopyIcon } from "@phosphor-icons/react"; +import { LinkSimpleIcon } from "@phosphor-icons/react"; +import { QrCodeIcon } from "@phosphor-icons/react"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { type SubmitHandler, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -54,10 +52,10 @@ const DEFAULT_OG_DATA: OgData = { }; interface LinkSheetProps { - open: boolean; - onOpenChange: (open: boolean) => void; link?: Link | null; + onOpenChange: (open: boolean) => void; onSave?: (link: Link) => void; + open: boolean; } function LinkSheetInner({ open, onOpenChange, link, onSave }: LinkSheetProps) { diff --git a/apps/dashboard/app/(main)/links/_components/link-utils.ts b/apps/dashboard/app/(main)/links/_components/link-utils.ts index 9c452f63e..ab7c34c12 100644 --- a/apps/dashboard/app/(main)/links/_components/link-utils.ts +++ b/apps/dashboard/app/(main)/links/_components/link-utils.ts @@ -73,25 +73,25 @@ interface BuildPayloadInput { androidUrl?: string; externalId?: string; }; - utmParams: UtmParams; ogData: OgData; useCustomOg: boolean; + utmParams: UtmParams; } interface LinkPayload { - name: string; - targetUrl: string; - slug: string | undefined; + androidUrl: string | undefined; + expiredRedirectUrl: string | undefined; expiresAtDate: Date | undefined; expiresAtString: string | undefined; - expiredRedirectUrl: string | undefined; - ogTitle: string | undefined; + externalId: string | undefined; + iosUrl: string | undefined; + name: string; ogDescription: string | undefined; ogImageUrl: string | undefined; + ogTitle: string | undefined; ogVideoUrl: string | undefined; - iosUrl: string | undefined; - androidUrl: string | undefined; - externalId: string | undefined; + slug: string | undefined; + targetUrl: string; } export function buildLinkPayload({ diff --git a/apps/dashboard/app/(main)/links/_components/links-search-bar.tsx b/apps/dashboard/app/(main)/links/_components/links-search-bar.tsx index 09d688f7c..b07d1cb57 100644 --- a/apps/dashboard/app/(main)/links/_components/links-search-bar.tsx +++ b/apps/dashboard/app/(main)/links/_components/links-search-bar.tsx @@ -1,11 +1,9 @@ "use client"; -import { - FunnelIcon, - MagnifyingGlassIcon, - SortAscendingIcon, - XIcon, -} from "@phosphor-icons/react"; +import { FunnelIcon } from "@phosphor-icons/react"; +import { MagnifyingGlassIcon } from "@phosphor-icons/react"; +import { SortAscendingIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -28,10 +26,10 @@ const SORT_LABELS: Record = { }; interface LinksSearchBarProps { - searchQuery: string; onSearchQueryChangeAction: (query: string) => void; - sortBy: SortOption; onSortByChangeAction: (sort: SortOption) => void; + searchQuery: string; + sortBy: SortOption; } export function LinksSearchBar({ diff --git a/apps/dashboard/app/(main)/links/_components/og-preview.tsx b/apps/dashboard/app/(main)/links/_components/og-preview.tsx index e411b3caa..a28225b6a 100644 --- a/apps/dashboard/app/(main)/links/_components/og-preview.tsx +++ b/apps/dashboard/app/(main)/links/_components/og-preview.tsx @@ -1,15 +1,13 @@ "use client"; -import { - ArrowCounterClockwiseIcon, - ArrowsClockwiseIcon, - CheckCircleIcon, - CircleNotchIcon, - XIcon as CloseIcon, - ImageIcon, - VideoIcon, - WarningCircleIcon, -} from "@phosphor-icons/react"; +import { ArrowCounterClockwiseIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { CircleNotchIcon } from "@phosphor-icons/react"; +import { XIcon as CloseIcon } from "@phosphor-icons/react"; +import { ImageIcon } from "@phosphor-icons/react"; +import { VideoIcon } from "@phosphor-icons/react"; +import { WarningCircleIcon } from "@phosphor-icons/react"; import { useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -24,9 +22,9 @@ import { } from "./use-og-metadata"; export interface OgData { - ogTitle: string; ogDescription: string; ogImageUrl: string; + ogTitle: string; ogVideoUrl: string; } @@ -34,11 +32,11 @@ const TITLE_MAX = 120; const DESCRIPTION_MAX = 240; interface OgPreviewProps { - targetUrl: string; - value: OgData; onChange: (data: OgData) => void; - useCustomOg: boolean; onUseCustomOgChange: (useCustom: boolean) => void; + targetUrl: string; + useCustomOg: boolean; + value: OgData; } export function OgPreview({ diff --git a/apps/dashboard/app/(main)/links/_components/qr-code-dialog.tsx b/apps/dashboard/app/(main)/links/_components/qr-code-dialog.tsx index 60714e6e6..3b41d273e 100644 --- a/apps/dashboard/app/(main)/links/_components/qr-code-dialog.tsx +++ b/apps/dashboard/app/(main)/links/_components/qr-code-dialog.tsx @@ -12,8 +12,8 @@ import { LinkQrCode } from "./link-qr-code"; interface QrCodeDialogProps { link: Link | null; - open: boolean; onOpenChange: (open: boolean) => void; + open: boolean; } export function QrCodeDialog({ link, open, onOpenChange }: QrCodeDialogProps) { diff --git a/apps/dashboard/app/(main)/links/_components/use-og-metadata.ts b/apps/dashboard/app/(main)/links/_components/use-og-metadata.ts index 6b383f2bd..831350053 100644 --- a/apps/dashboard/app/(main)/links/_components/use-og-metadata.ts +++ b/apps/dashboard/app/(main)/links/_components/use-og-metadata.ts @@ -5,9 +5,9 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useState } from "react"; export interface FetchedOgData { - title: string; description: string; image: string; + title: string; } const TRUSTED_IMAGE_HOSTS = new Set(["cdn.databuddy.cc", "api.dicebear.com"]); diff --git a/apps/dashboard/app/(main)/links/_components/utm-builder.tsx b/apps/dashboard/app/(main)/links/_components/utm-builder.tsx index dc8909736..b7c6bd464 100644 --- a/apps/dashboard/app/(main)/links/_components/utm-builder.tsx +++ b/apps/dashboard/app/(main)/links/_components/utm-builder.tsx @@ -7,17 +7,17 @@ import { Label } from "@/components/ui/label"; const PROTOCOL_REGEX = /^https?:\/\//; export interface UtmParams { - utm_source: string; - utm_medium: string; utm_campaign: string; utm_content: string; + utm_medium: string; + utm_source: string; utm_term: string; } interface UtmBuilderProps { - value: UtmParams; - onChange: (params: UtmParams) => void; baseUrl?: string; + onChange: (params: UtmParams) => void; + value: UtmParams; } const UTM_FIELDS = [ diff --git a/apps/dashboard/app/(main)/links/page.tsx b/apps/dashboard/app/(main)/links/page.tsx index 881b31871..99f009e5a 100644 --- a/apps/dashboard/app/(main)/links/page.tsx +++ b/apps/dashboard/app/(main)/links/page.tsx @@ -5,8 +5,8 @@ import { MagnifyingGlassIcon, PlusIcon, TrendDownIcon, -} from "@phosphor-icons/react/dist/ssr"; -import { LinkIcon } from "@phosphor-icons/react/dist/ssr/Link"; +} from "@phosphor-icons/react"; +import { LinkIcon } from "@phosphor-icons/react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; import { PageHeader } from "@/app/(main)/websites/_components/page-header"; diff --git a/apps/dashboard/app/(main)/llm/_components/llm-kpis.tsx b/apps/dashboard/app/(main)/llm/_components/llm-kpis.tsx deleted file mode 100644 index 4e039f71e..000000000 --- a/apps/dashboard/app/(main)/llm/_components/llm-kpis.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import { ChartLineUpIcon } from "@phosphor-icons/react/dist/ssr/ChartLineUp"; -import { ClockIcon } from "@phosphor-icons/react/dist/ssr/Clock"; -import { CpuIcon } from "@phosphor-icons/react/dist/ssr/Cpu"; -import { CurrencyDollarIcon } from "@phosphor-icons/react/dist/ssr/CurrencyDollar"; -import { LightningIcon } from "@phosphor-icons/react/dist/ssr/Lightning"; -import { WarningCircleIcon } from "@phosphor-icons/react/dist/ssr/WarningCircle"; -import { WrenchIcon } from "@phosphor-icons/react/dist/ssr/Wrench"; -import { StatCard } from "@/components/analytics/stat-card"; -import { - formatCurrency, - formatDuration, - formatNumber, - formatPercentage, - type LLMKpiData, -} from "./llm-types"; - -interface LLMKpisProps { - kpis: LLMKpiData | null; - isLoading: boolean; - chartData: { - cost: Array<{ date: string; value: number }>; - calls: Array<{ date: string; value: number }>; - tokens: Array<{ date: string; value: number }>; - latency: Array<{ date: string; value: number }>; - }; -} - -export function LLMPrimaryKpis({ kpis, isLoading, chartData }: LLMKpisProps) { - return ( -
- - - - -
- ); -} - -export function LLMSecondaryKpis({ - kpis, - isLoading, -}: Omit) { - return ( -
- - - - - - -
- ); -} diff --git a/apps/dashboard/app/(main)/llm/_components/llm-page-context.tsx b/apps/dashboard/app/(main)/llm/_components/llm-page-context.tsx deleted file mode 100644 index b92582397..000000000 --- a/apps/dashboard/app/(main)/llm/_components/llm-page-context.tsx +++ /dev/null @@ -1,126 +0,0 @@ -"use client"; - -import { - createContext, - useCallback, - useContext, - useMemo, - useRef, - useState, -} from "react"; -import { useOrganizationsContext } from "@/components/providers/organizations-provider"; -import { useWebsitesLight } from "@/hooks/use-websites"; -import dayjs from "@/lib/dayjs"; - -type RefreshFn = () => void; - -interface LLMPageContextValue { - selectedWebsiteId: string | null; - setSelectedWebsiteId: (id: string | null) => void; - selectedWebsite: { id: string; name: string; domain: string } | undefined; - websites: Array<{ id: string; name: string; domain: string }>; - isLoadingWebsites: boolean; - queryOptions: { websiteId?: string; organizationId?: string }; - hasQueryId: boolean; - dateRange: { - start_date: string; - end_date: string; - granularity: "daily"; - }; - isLoadingOrg: boolean; - registerRefresh: (fn: RefreshFn) => () => void; - refresh: () => void; - isFetching: boolean; - setIsFetching: (fetching: boolean) => void; -} - -const LLMPageContext = createContext(null); - -export const DEFAULT_DATE_RANGE = { - start_date: dayjs().subtract(30, "day").format("YYYY-MM-DD"), - end_date: dayjs().format("YYYY-MM-DD"), - granularity: "daily" as const, -}; - -export function LLMPageProvider({ children }: { children: React.ReactNode }) { - const { - activeOrganization, - activeOrganizationId, - isLoading: isLoadingOrg, - } = useOrganizationsContext(); - const { websites, isLoading: isLoadingWebsites } = useWebsitesLight(); - const [selectedWebsiteId, setSelectedWebsiteId] = useState( - null - ); - const [isFetching, setIsFetching] = useState(false); - const refreshFnsRef = useRef>(new Set()); - - const registerRefresh = useCallback((fn: RefreshFn) => { - refreshFnsRef.current.add(fn); - return () => { - refreshFnsRef.current.delete(fn); - }; - }, []); - - const refresh = useCallback(() => { - for (const fn of refreshFnsRef.current) { - fn(); - } - }, []); - - const queryOptions = useMemo(() => { - if (selectedWebsiteId) { - return { websiteId: selectedWebsiteId }; - } - return {}; - }, [selectedWebsiteId]); - - const hasQueryId = !!( - selectedWebsiteId || - activeOrganization?.id || - activeOrganizationId - ); - const selectedWebsite = websites.find((w) => w.id === selectedWebsiteId); - - const value = useMemo( - () => ({ - selectedWebsiteId, - setSelectedWebsiteId, - selectedWebsite, - websites, - isLoadingWebsites, - queryOptions, - hasQueryId, - dateRange: DEFAULT_DATE_RANGE, - isLoadingOrg, - registerRefresh, - refresh, - isFetching, - setIsFetching, - }), - [ - selectedWebsiteId, - selectedWebsite, - websites, - isLoadingWebsites, - queryOptions, - hasQueryId, - isLoadingOrg, - registerRefresh, - refresh, - isFetching, - ] - ); - - return ( - {children} - ); -} - -export function useLLMPageContext() { - const context = useContext(LLMPageContext); - if (!context) { - throw new Error("useLLMPageContext must be used within LLMPageProvider"); - } - return context; -} diff --git a/apps/dashboard/app/(main)/llm/_components/llm-page-header.tsx b/apps/dashboard/app/(main)/llm/_components/llm-page-header.tsx deleted file mode 100644 index 31b52ca8e..000000000 --- a/apps/dashboard/app/(main)/llm/_components/llm-page-header.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { ArrowClockwiseIcon } from "@phosphor-icons/react/dist/ssr/ArrowClockwise"; -import { CaretDownIcon } from "@phosphor-icons/react/dist/ssr/CaretDown"; -import { RobotIcon } from "@phosphor-icons/react/dist/ssr/Robot"; -import { PageHeader } from "@/app/(main)/websites/_components/page-header"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useLLMPageContext } from "./llm-page-context"; - -export function LLMPageHeader() { - const { - setSelectedWebsiteId, - selectedWebsite, - websites, - isLoadingWebsites, - refresh, - isFetching, - hasQueryId, - } = useLLMPageContext(); - - return ( - } - right={ -
- - - - - - setSelectedWebsiteId(null)}> - All Websites - - {websites.map((website) => ( - setSelectedWebsiteId(website.id)} - > - {website.name || website.domain} - - ))} - - - -
- } - title="LLM Analytics" - /> - ); -} diff --git a/apps/dashboard/app/(main)/llm/_components/llm-tables.tsx b/apps/dashboard/app/(main)/llm/_components/llm-tables.tsx deleted file mode 100644 index 1d4b43bb1..000000000 --- a/apps/dashboard/app/(main)/llm/_components/llm-tables.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"use client"; - -import { RobotIcon } from "@phosphor-icons/react/dist/ssr/Robot"; -import { WrenchIcon } from "@phosphor-icons/react/dist/ssr/Wrench"; -import type { ColumnDef } from "@tanstack/react-table"; -import { useMemo } from "react"; -import { DataTable } from "@/components/table/data-table"; -import { PercentageBadge } from "@/components/ui/percentage-badge"; -import { TruncatedText } from "@/components/ui/truncated-text"; -import { - formatCurrency, - formatDuration, - formatNumber, - type LLMModelData, - type LLMToolData, -} from "./llm-types"; - -// Helper components -function createMetricDisplay( - value: number, - label: string, - format = formatNumber -) { - return ( -
-
{format(value)}
-
{label}
-
- ); -} - -function createToolIndicator() { - return
; -} - -function createModelIndicator() { - return
; -} - -// Tool columns -function createToolColumns( - totalCalls: number -): ColumnDef[] { - return [ - { - id: "name", - accessorKey: "name", - header: "Tool Name", - cell: ({ getValue }) => { - const name = getValue() as string; - return ( -
- {createToolIndicator()} - - -
- ); - }, - }, - { - id: "calls", - accessorKey: "calls", - header: "Calls", - cell: ({ getValue }) => - createMetricDisplay(getValue() as number, "total"), - }, - { - id: "percentage", - accessorKey: "calls", - header: "Share", - cell: ({ getValue }) => { - const calls = getValue() as number; - const percentage = totalCalls > 0 ? (calls / totalCalls) * 100 : 0; - return ; - }, - }, - ]; -} - -// Model columns -function createModelColumns( - totalCost: number -): ColumnDef[] { - return [ - { - id: "name", - accessorKey: "name", - header: "Model", - cell: ({ row }) => { - const model = row.original; - return ( -
- {createModelIndicator()} - -
- - - {model.provider} - -
-
- ); - }, - }, - { - id: "calls", - accessorKey: "calls", - header: "Requests", - cell: ({ getValue }) => - createMetricDisplay(getValue() as number, "total"), - }, - { - id: "total_tokens", - accessorKey: "total_tokens", - header: "Tokens", - cell: ({ getValue }) => createMetricDisplay(getValue() as number, "used"), - }, - { - id: "avg_duration_ms", - accessorKey: "avg_duration_ms", - header: "Latency", - cell: ({ getValue }) => - createMetricDisplay(getValue() as number, "avg", formatDuration), - }, - { - id: "total_cost", - accessorKey: "total_cost", - header: "Cost", - cell: ({ getValue }) => - createMetricDisplay(getValue() as number, "total", formatCurrency), - }, - { - id: "percentage", - accessorKey: "total_cost", - header: "Share", - cell: ({ getValue }) => { - const cost = getValue() as number; - const percentage = totalCost > 0 ? (cost / totalCost) * 100 : 0; - return ; - }, - }, - ]; -} - -interface LLMTablesProps { - tools: LLMToolData[]; - models: LLMModelData[]; - isLoading: boolean; -} - -export function LLMTables({ tools, models, isLoading }: LLMTablesProps) { - // Calculate totals for percentage - const totalToolCalls = useMemo( - () => tools.reduce((sum, t) => sum + (t.calls || 0), 0), - [tools] - ); - const totalModelCost = useMemo( - () => models.reduce((sum, m) => sum + (m.total_cost || 0), 0), - [models] - ); - - // Transform data for DataTable (needs `name` field) - const toolsTableData = useMemo( - () => tools.map((t) => ({ ...t, name: t.tool_name })), - [tools] - ); - const modelsTableData = useMemo( - () => models.map((m) => ({ ...m, name: m.model })), - [models] - ); - - const toolColumns = useMemo( - () => createToolColumns(totalToolCalls), - [totalToolCalls] - ); - const modelColumns = useMemo( - () => createModelColumns(totalModelCost), - [totalModelCost] - ); - - return ( -
- - -
- ); -} diff --git a/apps/dashboard/app/(main)/llm/_components/llm-time-series-chart.tsx b/apps/dashboard/app/(main)/llm/_components/llm-time-series-chart.tsx deleted file mode 100644 index 0b859c575..000000000 --- a/apps/dashboard/app/(main)/llm/_components/llm-time-series-chart.tsx +++ /dev/null @@ -1,342 +0,0 @@ -"use client"; - -import { ChartLineIcon } from "@phosphor-icons/react/dist/ssr/ChartLine"; -import { useCallback, useState } from "react"; -import { SkeletonChart } from "@/components/charts/skeleton-chart"; -import { Chart } from "@/components/ui/composables/chart"; -import { - chartAxisTickDefault, - chartAxisYWidthDefault, - chartCartesianGridDefault, - chartRechartsInteractiveLegendLabelClassName, - chartRechartsLegendIconSize, - chartRechartsLegendInteractiveWrapperStyle, - chartSurfaceBorderlessClassName, -} from "@/lib/chart-presentation"; -import { - formatCurrency, - formatDuration, - formatNumber, - type LLMTimeSeriesData, -} from "./llm-types"; - -const { - Area, - CartesianGrid, - ComposedChart, - Legend, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} = Chart.Recharts; - -interface MetricConfig { - key: string; - label: string; - color: string; - gradient: string; - formatValue: (value: number) => string; -} - -const METRICS: MetricConfig[] = [ - { - key: "total_cost", - label: "Cost", - color: "var(--chart-1)", - gradient: "llm-cost", - formatValue: formatCurrency, - }, - { - key: "total_calls", - label: "Requests", - color: "#3b82f6", - gradient: "llm-calls", - formatValue: formatNumber, - }, - { - key: "total_tokens", - label: "Tokens", - color: "#10b981", - gradient: "llm-tokens", - formatValue: formatNumber, - }, - { - key: "avg_duration_ms", - label: "Latency", - color: "#f59e0b", - gradient: "llm-latency", - formatValue: formatDuration, - }, -]; - -interface TooltipPayloadEntry { - dataKey: string; - value: number; - color: string; - payload: Record; -} - -interface TooltipProps { - active?: boolean; - payload?: TooltipPayloadEntry[]; - label?: string; -} - -function CustomTooltip({ active, payload, label }: TooltipProps) { - if (!(active && payload?.length)) { - return null; - } - - return ( -
-
-
-

{label}

-
-
- {payload.map((entry) => { - const metric = METRICS.find((m) => m.key === entry.dataKey); - if (!metric || entry.value === undefined || entry.value === null) { - return null; - } - - return ( -
-
-
- - {metric.label} - -
- - {metric.formatValue(entry.value)} - -
- ); - })} -
-
- ); -} - -interface LLMTimeSeriesChartProps { - data: LLMTimeSeriesData[]; - isLoading: boolean; - height?: number; -} - -export function LLMTimeSeriesChart({ - data, - isLoading, - height = 350, -}: LLMTimeSeriesChartProps) { - const [hiddenMetrics, setHiddenMetrics] = useState>( - {} - ); - const [hasAnimated, setHasAnimated] = useState(false); - - const toggleMetric = useCallback((key: string) => { - setHiddenMetrics((prev) => ({ - ...prev, - [key]: !prev[key], - })); - }, []); - - if (isLoading) { - return ( -
-
-
-

- Usage Over Time -

-

- Daily cost, requests, and performance trends -

-
-
-
- -
-
- ); - } - - if (data.length === 0) { - return ( -
-
-
-

- Usage Over Time -

-

- Daily cost, requests, and performance trends -

-
-
-
-
-
- -
-

- No data available -

-

- Your LLM analytics data will appear here as AI calls are made -

-
-
-
- ); - } - - return ( -
-
-
-

- Usage Over Time -

-

- Daily cost, requests, and performance trends -

-
-
-
-
14 ? 800 : undefined }} - > -
-
- - 5 ? 60 : 20, - }} - > - - {METRICS.map((metric) => ( - - - - - ))} - - - - - - - - - } - cursor={Chart.tooltipCursorLine} - /> - - { - const metric = METRICS.find((m) => m.label === label); - const isHidden = metric - ? hiddenMetrics[metric.key] - : false; - return ( - - {label} - - ); - }} - iconSize={chartRechartsLegendIconSize} - iconType="circle" - onClick={(payload: { value: string }) => { - const metric = METRICS.find( - (m) => m.label === payload.value - ); - if (metric) { - toggleMetric(metric.key); - } - }} - verticalAlign="bottom" - wrapperStyle={chartRechartsLegendInteractiveWrapperStyle} - /> - - {METRICS.map((metric) => ( - { - setHasAnimated(true); - }} - stroke={metric.color} - strokeWidth={2.5} - type="monotone" - /> - ))} - - -
-
-
-
-
- ); -} diff --git a/apps/dashboard/app/(main)/llm/_components/llm-types.ts b/apps/dashboard/app/(main)/llm/_components/llm-types.ts deleted file mode 100644 index d9623f95d..000000000 --- a/apps/dashboard/app/(main)/llm/_components/llm-types.ts +++ /dev/null @@ -1,109 +0,0 @@ -export interface LLMKpiData { - total_calls: number; - total_cost: number; - total_tokens: number; - total_input_tokens: number; - total_output_tokens: number; - avg_duration_ms: number; - p75_duration_ms: number; - error_count: number; - error_rate: number; - cache_hit_rate: number; - tool_use_rate: number; - web_search_rate: number; -} - -export interface LLMTimeSeriesData { - date: string; - total_calls: number; - total_cost: number; - total_tokens: number; - avg_duration_ms: number; - p75_duration_ms: number; -} - -export interface LLMModelData { - name: string; - model: string; - provider: string; - calls: number; - total_cost: number; - total_tokens: number; - avg_duration_ms: number; - p75_duration_ms: number; - error_rate: number; -} - -export interface LLMToolData { - name: string; - tool_name: string; - calls: number; -} - -export interface LLMErrorSeriesData { - date: string; - error_count: number; - error_rate: number; -} - -export interface LLMErrorBreakdownData { - name: string; - error_name: string; - sample_message: string; - error_count: number; -} - -export interface LLMHttpStatusData { - name: string; - http_status: number; - calls: number; -} - -export interface LLMRecentErrorData { - name: string; - timestamp: string; - error_name: string; - error_message: string; - error_stack?: string; - model: string; - provider: string; - http_status?: number; - duration_ms: number; -} - -export function formatCurrency(value: number | null | undefined): string { - if (value === null || value === undefined) { - return "$0.00"; - } - if (value < 0.01 && value > 0) { - return `$${value.toFixed(4)}`; - } - return `$${value.toFixed(2)}`; -} - -export function formatNumber(value: number | null | undefined): string { - if (value === null || value === undefined) { - return "0"; - } - return Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); -} - -export function formatDuration(ms: number | null | undefined): string { - if (ms === null || ms === undefined || ms === 0) { - return "0ms"; - } - if (ms < 1000) { - return `${Math.round(ms)}ms`; - } - return `${(ms / 1000).toFixed(1)}s`; -} - -export function formatPercentage(value: number | null | undefined): string { - if (value === null || value === undefined) { - return "0%"; - } - return `${(value * 100).toFixed(1)}%`; -} diff --git a/apps/dashboard/app/(main)/llm/errors/_components/llm-error-detail-modal.tsx b/apps/dashboard/app/(main)/llm/errors/_components/llm-error-detail-modal.tsx deleted file mode 100644 index 53ee170f9..000000000 --- a/apps/dashboard/app/(main)/llm/errors/_components/llm-error-detail-modal.tsx +++ /dev/null @@ -1,412 +0,0 @@ -"use client"; - -import { - CheckIcon, - ClockIcon, - CodeIcon, - CopyIcon, - RobotIcon, - StackIcon, - TimerIcon, - WarningIcon, -} from "@phosphor-icons/react"; -import { useState } from "react"; -import { toast } from "sonner"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetBody, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; -import { fromNow } from "@/lib/time"; -import type { LLMRecentErrorData } from "../../_components/llm-types"; -import { formatDuration } from "../../_components/llm-types"; - -interface LLMErrorDetailModalProps { - error: LLMRecentErrorData; - isOpen: boolean; - onCloseAction: () => void; -} - -type CopiedSection = "name" | "message" | "stack" | "all" | null; - -function formatDateTimeSeconds(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -function getHttpStatusSeverity( - status?: number -): "high" | "medium" | "low" | null { - if (!status) { - return null; - } - if (status >= 500) { - return "high"; - } - if (status >= 400) { - return "medium"; - } - return "low"; -} - -function getSeverityColor(severity: "high" | "medium" | "low"): string { - const colors = { - high: "bg-destructive/10 text-destructive border-destructive/20", - medium: "bg-chart-2/10 text-chart-2 border-chart-2/20", - low: "bg-chart-3/10 text-chart-3 border-chart-3/20", - }; - return colors[severity]; -} - -const CopyButton = ({ - text, - section, - copiedSection, - onCopy, - ariaLabel, -}: { - text: string; - section: CopiedSection; - copiedSection: CopiedSection; - onCopy: (text: string, section: CopiedSection) => void; - ariaLabel?: string; -}) => { - const isCopied = copiedSection === section; - - return ( - - ); -}; - -export function LLMErrorDetailModal({ - error, - isOpen, - onCloseAction, -}: LLMErrorDetailModalProps) { - const [copiedSection, setCopiedSection] = useState(null); - - if (!error) { - return null; - } - - const copyToClipboard = async (text: string, section: CopiedSection) => { - try { - await navigator.clipboard.writeText(text); - setCopiedSection(section); - toast.success("Copied to clipboard"); - setTimeout(() => setCopiedSection(null), 2000); - } catch (err) { - toast.error("Failed to copy", { - description: err instanceof Error ? err.message : "Unknown error", - }); - } - }; - - const relativeTimeStr = fromNow(error.timestamp); - const httpSeverity = getHttpStatusSeverity(error.http_status); - - const fullErrorInfo = `LLM Error: ${error.error_name} -${error.error_message ? `\nMessage:\n${error.error_message}` : ""} -${error.error_stack ? `\nStack Trace:\n${error.error_stack}` : ""} - -Context: -• Model: ${error.model} -• Provider: ${error.provider} -• HTTP Status: ${error.http_status ?? "N/A"} -• Latency: ${formatDuration(error.duration_ms)} -• Time: ${formatDateTimeSeconds(error.timestamp)}`; - - const contextRows = [ - { - key: "model", - label: "Model", - value: error.model, - icon: ( - - ), - }, - { - key: "provider", - label: "Provider", - value: error.provider, - icon: ( - - ), - }, - { - key: "http_status", - label: "HTTP Status", - value: error.http_status ? String(error.http_status) : "—", - icon: ( - - ), - }, - { - key: "latency", - label: "Latency", - value: formatDuration(error.duration_ms), - icon: ( - - ), - }, - ]; - - return ( - - - -
-
- -
-
-
- - {error.error_name} - - {httpSeverity && ( - - {error.http_status} - - )} -
- - - {relativeTimeStr} - - - {formatDateTimeSeconds(error.timestamp)} - - -
-
-
- - - {/* Quick Actions */} -
- - Quick actions - -
- - {error.error_message && ( - - )} - {error.error_stack && ( - - )} -
-
- - {/* Error Name */} -
-
-
- - - Error Name - -
- -
-
-

- {error.error_name} -

-
-
- - {/* Error Message */} - {error.error_message && ( -
-
-
- - - Error Message - -
- -
-
-
-									{error.error_message}
-								
-
-
- )} - - {/* Stack Trace */} - {error.error_stack && ( -
- - - -
- - - Stack Trace - -
-
- -
-
-												{error.error_stack}
-											
-
- -
-
-
-
-
-
- )} - - {/* Context */} -
- Context -
- {contextRows.map((row, index) => ( -
0 ? "border-t" : ""}`} - key={row.key} - > -
- {row.icon} -
- - {row.label} - -

- {row.value} -

-
-
-
- ))} -
-
-
- - - - - -
-
- ); -} diff --git a/apps/dashboard/app/(main)/llm/errors/page.tsx b/apps/dashboard/app/(main)/llm/errors/page.tsx deleted file mode 100644 index a86f5821e..000000000 --- a/apps/dashboard/app/(main)/llm/errors/page.tsx +++ /dev/null @@ -1,455 +0,0 @@ -"use client"; - -import { ClockIcon, RobotIcon, WarningIcon } from "@phosphor-icons/react"; -import type { ColumnDef } from "@tanstack/react-table"; -import { useCallback, useMemo, useState } from "react"; -import { SimpleMetricsChart } from "@/components/charts/simple-metrics-chart"; -import { DataTable, type TabConfig } from "@/components/table/data-table"; -import { Badge } from "@/components/ui/badge"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useBatchDynamicQuery } from "@/hooks/use-dynamic-query"; -import dayjs from "@/lib/dayjs"; -import { useLLMPageContext } from "../_components/llm-page-context"; -import { - formatDuration, - formatNumber, - type LLMErrorBreakdownData, - type LLMErrorSeriesData, - type LLMHttpStatusData, - type LLMRecentErrorData, -} from "../_components/llm-types"; -import { LLMErrorDetailModal } from "./_components/llm-error-detail-modal"; - -function getRelativeTime(timestamp: string): string { - const date = dayjs(timestamp); - if (!date.isValid()) { - return ""; - } - return date.fromNow(); -} - -function formatDateTimeSeconds(timestamp: string): string { - const date = new Date(timestamp); - return date.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -function getHttpStatusSeverity( - status?: number -): "high" | "medium" | "low" | null { - if (!status) { - return null; - } - if (status >= 500) { - return "high"; - } - if (status >= 400) { - return "medium"; - } - return "low"; -} - -function getSeverityColor(severity: "high" | "medium" | "low"): string { - const colors = { - high: "bg-destructive/10 text-destructive border-destructive/20", - medium: "bg-chart-2/10 text-chart-2 border-chart-2/20", - low: "bg-chart-3/10 text-chart-3 border-chart-3/20", - }; - return colors[severity]; -} - -const SeverityDot = ({ severity }: { severity: "high" | "medium" | "low" }) => { - const colors = { - high: "bg-destructive", - medium: "bg-chart-2", - low: "bg-chart-3", - }; - return ( - - ); -}; - -const errorBreakdownColumns: ColumnDef< - LLMErrorBreakdownData & { name: string } ->[] = [ - { - id: "error_name", - accessorKey: "error_name", - header: "Error", - cell: ({ row }) => ( -
- - - {row.original.error_name} - -
- ), - }, - { - id: "error_count", - accessorKey: "error_count", - header: "Count", - cell: ({ row }) => ( - - {formatNumber(row.original.error_count)} - - ), - }, - { - id: "sample_message", - accessorKey: "sample_message", - header: "Sample Message", - cell: ({ row }) => ( - - - - {row.original.sample_message || "—"} - - - {row.original.sample_message && ( - -

- {row.original.sample_message} -

-
- )} -
- ), - }, -]; - -const httpStatusColumns: ColumnDef[] = [ - { - id: "http_status", - accessorKey: "http_status", - header: "Status Code", - cell: ({ row }) => { - const status = row.original.http_status; - const severity = getHttpStatusSeverity(status); - return ( -
- {severity && } - {status} -
- ); - }, - }, - { - id: "calls", - accessorKey: "calls", - header: "Count", - cell: ({ row }) => ( - {formatNumber(row.original.calls)} - ), - }, -]; - -export default function LLMErrorsPage() { - const { queryOptions, dateRange, hasQueryId, isLoadingOrg } = - useLLMPageContext(); - - const [selectedError, setSelectedError] = useState( - null - ); - const [isModalOpen, setIsModalOpen] = useState(false); - - const handleViewError = useCallback((error: LLMRecentErrorData) => { - setSelectedError(error); - setIsModalOpen(true); - }, []); - - const handleCloseModal = useCallback(() => { - setIsModalOpen(false); - setSelectedError(null); - }, []); - - const queries = useMemo( - () => [ - { id: "llm-error-series", parameters: ["llm_error_rate_time_series"] }, - { id: "llm-errors", parameters: ["llm_error_breakdown"] }, - { id: "llm-status", parameters: ["llm_http_status_breakdown"] }, - { id: "llm-recent-errors", parameters: ["llm_recent_errors"] }, - ], - [] - ); - - const { isLoading, getDataForQuery } = useBatchDynamicQuery( - queryOptions, - dateRange, - queries, - { enabled: hasQueryId } - ); - - const errorSeries = - (getDataForQuery( - "llm-error-series", - "llm_error_rate_time_series" - ) as LLMErrorSeriesData[]) ?? []; - - const errorBreakdown = - (getDataForQuery( - "llm-errors", - "llm_error_breakdown" - ) as LLMErrorBreakdownData[]) ?? []; - - const statusBreakdown = - (getDataForQuery( - "llm-status", - "llm_http_status_breakdown" - ) as LLMHttpStatusData[]) ?? []; - - const recentErrors = - (getDataForQuery( - "llm-recent-errors", - "llm_recent_errors" - ) as LLMRecentErrorData[]) ?? []; - - const chartData = useMemo( - () => - errorSeries.map((row) => ({ - date: row.date, - errors: row.error_count ?? 0, - rate: (row.error_rate ?? 0) * 100, - })), - [errorSeries] - ); - - const breakdownTabs = useMemo(() => { - const tabs: TabConfig< - (LLMErrorBreakdownData | LLMHttpStatusData) & { name: string } - >[] = []; - - if (errorBreakdown.length > 0) { - tabs.push({ - id: "errors", - label: "By Error Type", - data: errorBreakdown.map((row) => ({ ...row, name: row.error_name })), - columns: errorBreakdownColumns, - }); - } - - if (statusBreakdown.length > 0) { - tabs.push({ - id: "http-status", - label: "By HTTP Status", - data: statusBreakdown.map((row) => ({ - ...row, - name: String(row.http_status), - })), - columns: httpStatusColumns, - }); - } - - return tabs; - }, [errorBreakdown, statusBreakdown]); - - const recentErrorsData = useMemo( - () => recentErrors.map((row) => ({ ...row, name: row.error_name })), - [recentErrors] - ); - - const recentErrorColumns: ColumnDef[] = - useMemo( - () => [ - { - id: "severity", - accessorKey: "http_status", - header: "", - size: 32, - cell: ({ row }) => { - const severity = getHttpStatusSeverity(row.original.http_status); - return ( -
- {severity ? ( - - ) : ( - - )} -
- ); - }, - }, - { - id: "error", - accessorKey: "error_name", - header: "Error", - size: 400, - cell: ({ row }) => { - const error = row.original; - - return ( -
-
-
- -
- - {error.error_name} - -
-
- ); - }, - }, - { - id: "model", - accessorKey: "model", - header: "Model", - size: 160, - cell: ({ row }) => ( - - -
- - - {row.original.model} - -
-
- -
- Model: {row.original.model} - Provider: {row.original.provider} -
-
-
- ), - }, - { - id: "http_status", - accessorKey: "http_status", - header: "Status", - size: 80, - cell: ({ row }) => { - const status = row.original.http_status; - if (!status) { - return ; - } - const severity = getHttpStatusSeverity(status); - return ( - - {status} - - ); - }, - }, - { - id: "duration_ms", - accessorKey: "duration_ms", - header: "Latency", - cell: ({ row }) => ( - - {formatDuration(row.original.duration_ms)} - - ), - }, - { - id: "timestamp", - accessorKey: "timestamp", - header: "Time", - cell: ({ row }) => { - const time = row.original.timestamp; - const relative = getRelativeTime(time); - const full = formatDateTimeSeconds(time); - - return ( - - -
- - - {relative} - -
-
- - {full} - -
- ); - }, - }, - ], - [] - ); - - const isPageLoading = isLoadingOrg || isLoading; - - return ( -
- formatNumber(v), - }, - { - key: "rate", - label: "Error Rate", - color: "#f97316", - formatValue: (v) => { - const safeValue = v == null || Number.isNaN(v) ? 0 : v; - return `${safeValue.toFixed(1)}%`; - }, - }, - ]} - title="Error Trends" - /> - - {breakdownTabs.length > 0 && ( - - )} - - handleViewError(row)} - title="Recent Errors" - /> - - {selectedError && ( - - )} -
- ); -} diff --git a/apps/dashboard/app/(main)/llm/layout.tsx b/apps/dashboard/app/(main)/llm/layout.tsx deleted file mode 100644 index 4941fa7ae..000000000 --- a/apps/dashboard/app/(main)/llm/layout.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { BrainIcon, WarningIcon } from "@phosphor-icons/react/dist/ssr"; -import { PageNavigation } from "@/components/layout/page-navigation"; -import { LLMPageProvider } from "./_components/llm-page-context"; -import { LLMPageHeader } from "./_components/llm-page-header"; - -export default function LlmAnalyticsLayout({ - children, -}: { - children: React.ReactNode; -}) { - const basePath = "/llm"; - - return ( - -
- - -
- {children} -
-
-
- ); -} diff --git a/apps/dashboard/app/(main)/llm/page.tsx b/apps/dashboard/app/(main)/llm/page.tsx deleted file mode 100644 index 5fb74d8d7..000000000 --- a/apps/dashboard/app/(main)/llm/page.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { useEffect, useMemo } from "react"; -import { useBatchDynamicQuery } from "@/hooks/use-dynamic-query"; -import { LLMPrimaryKpis, LLMSecondaryKpis } from "./_components/llm-kpis"; -import { useLLMPageContext } from "./_components/llm-page-context"; -import { LLMTables } from "./_components/llm-tables"; -import { LLMTimeSeriesChart } from "./_components/llm-time-series-chart"; -import type { - LLMKpiData, - LLMModelData, - LLMTimeSeriesData, - LLMToolData, -} from "./_components/llm-types"; - -export default function LLMAnalyticsPage() { - const { - queryOptions, - dateRange, - hasQueryId, - isLoadingOrg, - registerRefresh, - setIsFetching, - } = useLLMPageContext(); - - const queries = useMemo( - () => [ - { - id: "llm-kpis", - parameters: ["llm_overview_kpis"], - limit: 1, - granularity: dateRange.granularity, - }, - { - id: "llm-time-series", - parameters: ["llm_time_series"], - limit: 365, - granularity: dateRange.granularity, - }, - { - id: "llm-models", - parameters: ["llm_model_breakdown"], - limit: 20, - granularity: dateRange.granularity, - }, - { - id: "llm-tools", - parameters: ["llm_tool_name_breakdown"], - limit: 20, - granularity: dateRange.granularity, - }, - ], - [dateRange.granularity] - ); - - const { isLoading, getDataForQuery, refetch, isFetching } = - useBatchDynamicQuery(queryOptions, dateRange, queries, { - enabled: hasQueryId, - }); - - useEffect(() => { - return registerRefresh(refetch); - }, [registerRefresh, refetch]); - - useEffect(() => { - setIsFetching(isFetching); - }, [isFetching, setIsFetching]); - - const kpis = - (getDataForQuery("llm-kpis", "llm_overview_kpis") as LLMKpiData[])?.[0] || - null; - const timeSeries = - (getDataForQuery( - "llm-time-series", - "llm_time_series" - ) as LLMTimeSeriesData[]) || []; - const models = - (getDataForQuery("llm-models", "llm_model_breakdown") as LLMModelData[]) || - []; - const tools = - (getDataForQuery( - "llm-tools", - "llm_tool_name_breakdown" - ) as LLMToolData[]) || []; - - const chartData = useMemo( - () => ({ - cost: timeSeries.map((d) => ({ - date: d.date, - value: d.total_cost || 0, - })), - calls: timeSeries.map((d) => ({ - date: d.date, - value: d.total_calls || 0, - })), - tokens: timeSeries.map((d) => ({ - date: d.date, - value: d.total_tokens || 0, - })), - latency: timeSeries.map((d) => ({ - date: d.date, - value: d.avg_duration_ms || 0, - })), - }), - [timeSeries] - ); - - const isPageLoading = isLoadingOrg || isLoading; - - return ( -
- {/* KPIs */} -
- - -
- - {/* Time Series Chart */} - - - {/* Tools & Models Tables */} - -
- ); -} diff --git a/apps/dashboard/app/(main)/monitors/[id]/page.tsx b/apps/dashboard/app/(main)/monitors/[id]/page.tsx index 83f8b5323..f922b3f00 100644 --- a/apps/dashboard/app/(main)/monitors/[id]/page.tsx +++ b/apps/dashboard/app/(main)/monitors/[id]/page.tsx @@ -1,5 +1,21 @@ "use client"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { ArrowLeftIcon } from "@phosphor-icons/react"; +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { GlobeIcon } from "@phosphor-icons/react"; +import { HeartbeatIcon } from "@phosphor-icons/react"; +import { LightningIcon } from "@phosphor-icons/react"; +import { PauseIcon } from "@phosphor-icons/react"; +import { PencilIcon } from "@phosphor-icons/react"; +import { PlayIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; +import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; import { MonitorDetailLoading } from "@/app/(main)/monitors/_components/monitor-detail-loading"; import { PageHeader } from "@/app/(main)/websites/_components/page-header"; import { FaviconImage } from "@/components/analytics/favicon-image"; @@ -25,23 +41,6 @@ import { fromNow, localDayjs } from "@/lib/time"; import { LatencyChartChunkPlaceholder } from "@/lib/uptime/latency-chart-chunk-placeholder"; import { UptimeHeatmap } from "@/lib/uptime/uptime-heatmap"; import { cn } from "@/lib/utils"; -import { - ArrowClockwiseIcon, - ArrowLeftIcon, - ArrowSquareOutIcon, - GlobeIcon, - HeartbeatIcon, - PauseIcon, - PencilIcon, - PlayIcon, - TrashIcon, -} from "@phosphor-icons/react"; -import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; import { RecentActivity, type RecentActivityCheck, @@ -73,22 +72,24 @@ const granularityLabels: Record = { }; interface ScheduleData { - id: string; - organizationId: string; - websiteId: string | null; - url: string; - name: string | null; - granularity: string; + cacheBust: boolean; cron: string; + granularity: string; + id: string; isPaused: boolean; isPublic: boolean; - qstashStatus: string; jsonParsingConfig?: { enabled: boolean } | null; + name: string | null; + organizationId: string; + qstashStatus: string; + timeout: number | null; + url: string; website?: { id: string; name: string | null; domain: string; } | null; + websiteId: string | null; } function resolveStatus(check: RecentActivityCheck | undefined) { @@ -161,7 +162,8 @@ export default function MonitorDetailsPage() { url: string; name?: string | null; granularity: string; - isPublic?: boolean; + timeout?: number | null; + cacheBust?: boolean; jsonParsingConfig?: { enabled: boolean } | null; } | null>(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -209,6 +211,9 @@ export default function MonitorDetailsPage() { const deleteMutation = useMutation({ ...orpc.uptime.deleteSchedule.mutationOptions(), }); + const manualCheckMutation = useMutation({ + ...orpc.uptime.manualCheck.mutationOptions(), + }); const transferMutation = useMutation({ ...orpc.uptime.transfer.mutationOptions(), @@ -328,12 +333,15 @@ export default function MonitorDetailsPage() { "uptime_response_time_trends" ); - // --- Pagination effects --- + // --- Pagination: reset when filters change (render-time pattern) --- - useEffect(() => { + const paginationResetKey = `${dateRange.start_date}-${dateRange.end_date}-${scheduleId}`; + const [prevResetKey, setPrevResetKey] = useState(paginationResetKey); + if (prevResetKey !== paginationResetKey) { + setPrevResetKey(paginationResetKey); setRecentChecksPage(1); setAllRecentChecks([]); - }, [dateRange, scheduleId]); + } const recentChecksHasNext = pageRecentChecks.length === RECENT_CHECKS_PAGE_SIZE; @@ -407,7 +415,8 @@ export default function MonitorDetailsPage() { url: schedule.url, name: schedule.name, granularity: schedule.granularity, - isPublic: schedule.isPublic, + timeout: schedule.timeout, + cacheBust: schedule.cacheBust, jsonParsingConfig: schedule.jsonParsingConfig as { enabled: boolean; } | null, @@ -475,6 +484,26 @@ export default function MonitorDetailsPage() { setIsRefreshing(false); }; + const handleManualCheck = async () => { + if (!schedule) { + return; + } + try { + await manualCheckMutation.mutateAsync({ scheduleId: schedule.id }); + toast.success("Check triggered"); + setTimeout(() => { + refetchSchedule(); + refetchUptimeData(); + refetchHeatmapData(); + refetchLatencyData(); + }, 3000); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to trigger check"; + toast.error(errorMessage); + } + }; + const handleTransfer = async (targetOrganizationId: string) => { if (!schedule) { return; @@ -568,6 +597,21 @@ export default function MonitorDetailsPage() { className={isRefreshing ? "animate-spin" : ""} /> + + + +
+ ); +} diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-explore.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-explore.tsx new file mode 100644 index 000000000..14b227754 --- /dev/null +++ b/apps/dashboard/app/(main)/onboarding/_components/step-explore.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { + ArrowRightIcon, + ChartLineUpIcon, + CursorClickIcon, + LightningIcon, + RocketLaunchIcon, + UsersIcon, +} from "@phosphor-icons/react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +const FEATURES = [ + { + icon: ChartLineUpIcon, + title: "Analytics Overview", + description: + "Pageviews, visitors, bounce rate, and session duration at a glance.", + tab: "", + }, + { + icon: UsersIcon, + title: "Live Visitors", + description: "See who's on your site right now with real-time data.", + tab: "?tab=realtime", + }, + { + icon: CursorClickIcon, + title: "Custom Events", + description: "Track button clicks, form submissions, and any user action.", + tab: "/events", + }, + { + icon: LightningIcon, + title: "Web Vitals", + description: "Monitor Core Web Vitals and page load performance.", + tab: "/vitals", + }, +]; + +interface StepExploreProps { + onComplete: () => void; + websiteId: string; +} + +export function StepExplore({ onComplete, websiteId }: StepExploreProps) { + return ( +
+
+
+ +
+
+

You're all set

+

+ Here's what you can do with your dashboard. +

+
+
+ +
+ {FEATURES.map((feature) => ( + +
+ +
+
+

{feature.title}

+

+ {feature.description} +

+
+ + + ))} +
+ + +
+ ); +} diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx new file mode 100644 index 000000000..ec7a764ca --- /dev/null +++ b/apps/dashboard/app/(main)/onboarding/_components/step-install-tracking.tsx @@ -0,0 +1,609 @@ +"use client"; + +import { track } from "@databuddy/sdk"; +import { + ArrowClockwiseIcon, + CheckIcon, + ClipboardIcon, + CodeIcon, + PackageIcon, + PulseIcon, + SparkleIcon, + WarningCircleIcon, +} from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { createHighlighterCoreSync } from "shiki/core"; +import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; +import bash from "shiki/langs/bash.mjs"; +import html from "shiki/langs/html.mjs"; +import tsx from "shiki/langs/tsx.mjs"; +import vesper from "shiki/themes/vesper.mjs"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { orpc } from "@/lib/orpc"; +import { cn } from "@/lib/utils"; +import { + COPY_SUCCESS_TIMEOUT, + INSTALL_COMMANDS, +} from "../../websites/[id]/_components/shared/tracking-constants"; +import { + generateNpmCode, + generateScriptTag, +} from "../../websites/[id]/_components/utils/code-generators"; + +// TODO: Replace with published skill URL once available +const SKILL_URL = "https://github.com/databuddy-cc/skill"; + +function generateAgentPrompt(websiteId: string): string { + return `Add Databuddy analytics to this repository. Client ID: ${websiteId} + +## References +- Docs: https://www.databuddy.cc/docs/getting-started +- LLMs.txt: https://www.databuddy.cc/llms.txt +- Full docs: https://www.databuddy.cc/docs +- Skill (install for full context): ${SKILL_URL} + +## Installation + +Choose the right method for this project's framework: + +**React / Next.js** — \`bun add @databuddy/sdk\` (or npm/yarn/pnpm) +\`\`\`tsx +import { Databuddy } from "@databuddy/sdk/react"; +// Mount at the app root (layout.tsx or _app.tsx) + +\`\`\` + +**Vue** — \`bun add @databuddy/sdk\` +\`\`\`vue + + +\`\`\` + +**Vanilla JS / HTML** — CDN script in \`\`: +\`\`\`html + +\`\`\` + +Store the Client ID in an env var — never hardcode it. +- Next.js: NEXT_PUBLIC_DATABUDDY_CLIENT_ID +- Vue/Vite: VITE_DATABUDDY_CLIENT_ID + +## Configuration Options + +All options work as React/Vue props or \`data-*\` attributes on the script tag. +| Option | Type | Default | What it does | +|--------|------|---------|-------------| +| trackWebVitals | bool | false | Core Web Vitals (LCP, CLS, INP, TTFB) | +| trackPerformance | bool | true | Page load timing | +| trackErrors | bool | false | JavaScript errors and exceptions | +| trackHashChanges | bool | false | URL hash changes (SPA routing) | +| trackAttributes | bool | false | Auto-track elements with data-track attribute | +| trackOutgoingLinks | bool | false | Clicks to external sites | +| trackInteractions | bool | false | Button clicks and form submissions | +| trackSessions | bool | true | Session tracking (automatic) | +| trackScreenViews | bool | true | Page view tracking (automatic) | +| disabled | bool | false | Master kill switch | +| samplingRate | 0-1 | 1.0 | Fraction of events to capture | +| enableBatching | bool | true | Batch events before sending | +| batchSize | num | 10 | Events per batch | +| batchTimeout | num | 2000 | Max ms before flushing batch | +| enableRetries | bool | true | Retry failed requests | +| maxRetries | num | 3 | Max retry attempts | + +Enable what makes sense for this project. A good starting point: +\`\`\`tsx + +\`\`\` + +## Custom Events + +\`\`\`tsx +import { track } from "@databuddy/sdk"; +track("signup_completed", { method: "google", plan: "pro" }); +\`\`\` + +Use snake_case event names. Track decisions and milestones (signup_completed, purchase_completed, feature_used), not every click. Keep properties low-cardinality. Never track PII. + +## Verification — How to Confirm It Works + +1. Open DevTools → Network tab, reload the page +2. Look for a request to cdn.databuddy.cc/databuddy.js (script loading) +3. Look for requests to basket.databuddy.cc (events being sent) +4. Both should return 200. If events show the correct Client ID in the payload, tracking is working. + +## Common Issues & Fixes + +**Domain mismatch**: Events are rejected if sent from a domain that doesn't match the website configured in Databuddy. The domain in settings must match the domain the script runs on. + +**Content Security Policy (CSP)**: If the site has strict CSP headers, add these directives: +- script-src: https://cdn.databuddy.cc +- connect-src: https://basket.databuddy.cc + +**Ad blockers**: uBlock Origin, Privacy Badger, and similar extensions may block analytics scripts. Test with extensions disabled. For production, consider a custom tracking domain (proxy through your own domain). + +**Localhost is ignored by default**: The SDK does not send events from localhost in production builds. During development, events only fire if the dev server is running. + +**Script not loading**: Verify the script tag is in (not ), the src URL is correct, and no CSP or network error appears in the console. + +**Events not appearing in dashboard**: Data typically appears within 30 seconds. Check the Network tab for failed requests to basket.databuddy.cc. Verify the Client ID matches. Check for console errors. + +**If another analytics tool is present**: Both can run in parallel. No conflicts. Optionally disable the other tool's page view tracking if Databuddy handles it. + +## Report Back — Required + +After installation (success or failure), send a POST to report what happened. This helps us improve the setup experience. + +\`\`\`bash +curl -X POST https://api.databuddy.cc/public/v1/agent-telemetry \\ + -H "Content-Type: application/json" \\ + -d '{ + "websiteId": "${websiteId}", + "agent": "claude", + "status": "success", + "framework": "nextjs", + "installMethod": "sdk", + "stepsCompleted": ["install", "mount", "env-var", "verify"], + "issues": [], + "metadata": {} + }' +\`\`\` + +Fields: +- **websiteId**: "${websiteId}" +- **agent**: Which tool ran this (claude, cursor, copilot, windsurf, other) +- **status**: "success" | "partial" | "failed" +- **framework**: What framework was detected (nextjs, react, vue, vanilla, etc.) +- **installMethod**: "sdk" or "script-tag" +- **stepsCompleted**: Array of steps that succeeded: "install", "mount", "env-var", "verify" +- **issues**: Array of problems encountered, e.g. [{"type": "csp", "detail": "blocked cdn.databuddy.cc", "resolved": true}] + - Common issue types: csp, adblocker, domain-mismatch, script-blocked, build-error, type-error, env-var-missing +- **errorMessage**: Final error message if status is "failed" +- **metadata**: Any extra context (package manager used, versions, etc.) + +Always send this report, even on failure. It's a single fire-and-forget POST.`; +} + +function ClaudeLogo({ color }: { color: string }) { + return ( + + ); +} + +function CursorLogo({ color }: { color: string }) { + return ( + + ); +} + +function CopilotLogo({ color }: { color: string }) { + return ( + + ); +} + +function WindsurfLogo({ color }: { color: string }) { + return ( + + ); +} + +const AI_TOOLS = [ + { + id: "claude", + name: "Claude Code", + description: "Copy prompt for Claude", + color: "#D97757", + icon: ClaudeLogo, + }, + { + id: "cursor", + name: "Cursor", + description: "Copy prompt for Cursor", + color: "#00A0F0", + icon: CursorLogo, + }, + { + id: "copilot", + name: "GitHub Copilot", + description: "Copy prompt for Copilot", + color: "#6E40C9", + icon: CopilotLogo, + }, + { + id: "windsurf", + name: "Windsurf", + description: "Copy prompt for Windsurf", + color: "#00C48C", + icon: WindsurfLogo, + }, +]; + +const DEFAULT_TRACKING_OPTIONS = { + disabled: false, + trackHashChanges: false, + trackAttributes: false, + trackOutgoingLinks: false, + trackInteractions: false, + trackPerformance: false, + trackWebVitals: false, + trackErrors: false, + trackSessions: true, + trackScreenViews: false, + enableBatching: true, + enableRetries: true, + batchSize: 10, + batchTimeout: 5000, + maxRetries: 3, + initialRetryDelay: 1000, + samplingRate: 1, +}; + +const highlighter = createHighlighterCoreSync({ + themes: [vesper], + langs: [tsx, html, bash], + engine: createJavaScriptRegexEngine(), +}); + +function getLanguage(code: string): "bash" | "html" | "tsx" { + if ( + code.includes("npm install") || + code.includes("yarn add") || + code.includes("pnpm add") || + code.includes("bun add") + ) { + return "bash"; + } + if (code.includes(" void; +}) { + const highlighted = useMemo( + () => + highlighter.codeToHtml(code, { + lang: getLanguage(code), + theme: "vesper", + }), + [code] + ); + + return ( +
+
+
pre]:m-0 [&>pre]:overflow-visible [&>pre]:p-4 [&>pre]:leading-relaxed", + "[&>pre>code]:block [&>pre>code]:w-full", + "[&_.line]:min-h-5" + )} + dangerouslySetInnerHTML={{ __html: highlighted }} + /> + +
+
+ ); +} + +interface StepInstallTrackingProps { + onComplete: () => void; + websiteId: string; +} + +export function StepInstallTracking({ + websiteId, + onComplete, +}: StepInstallTrackingProps) { + const [copiedBlockId, setCopiedBlockId] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + const trackingCode = generateScriptTag(websiteId, DEFAULT_TRACKING_OPTIONS); + const npmCode = generateNpmCode(websiteId, DEFAULT_TRACKING_OPTIONS); + + const { data: trackingSetupData, refetch: refetchTrackingSetup } = useQuery({ + ...orpc.websites.isTrackingSetup.queryOptions({ input: { websiteId } }), + enabled: !!websiteId, + }); + + const isSetup = trackingSetupData?.tracking_setup ?? false; + + const handleCopy = (code: string, blockId: string, message: string) => { + try { + navigator.clipboard.writeText(code); + setCopiedBlockId(blockId); + toast.success(message); + setTimeout(() => setCopiedBlockId(null), COPY_SUCCESS_TIMEOUT); + try { + track("onboarding_tracking_copied", { + block: blockId, + method: AI_TOOLS.some((t) => t.id === blockId) + ? "ai" + : blockId.includes("install") + ? "sdk" + : "script", + }); + } catch {} + } catch { + toast.error("Failed to copy to clipboard"); + } + }; + + const handleCheckStatus = async () => { + setIsRefreshing(true); + try { + track("onboarding_tracking_check_status"); + } catch {} + try { + const result = await refetchTrackingSetup(); + if (result.data?.tracking_setup) { + toast.success("Tracking verified! Data is flowing."); + try { + track("onboarding_tracking_verified"); + } catch {} + onComplete(); + } else { + toast.info("No tracking detected yet. Check your installation."); + } + } catch { + toast.error("Couldn't verify tracking. Try again shortly."); + } finally { + setIsRefreshing(false); + } + }; + + return ( +
+
+
+ +
+
+

+ Install tracking +

+

+ Add a small script to your website to start collecting data. +

+
+
+ + {/* Status banner */} + + +
+ {isSetup ? ( + + ) : ( + + )} +
+ + {isSetup ? "Tracking Active" : "Awaiting Installation"} + + + {isSetup ? "Live" : "Pending"} + +
+
+ +
+
+ + {/* Installation tabs */} + + + + + Install with AI + + + + SDK Package + + + + Script Tag + + + + +

+ Let your AI assistant install Databuddy for you. Copy the setup + prompt into your tool of choice. +

+ +
+ {AI_TOOLS.map((tool) => ( + + ))} +
+ +

+ Works with any AI assistant that accepts text prompts +

+
+ + + + + {Object.keys(INSTALL_COMMANDS).map((manager) => ( + + {manager} + + ))} + + {Object.entries(INSTALL_COMMANDS).map(([manager, command]) => ( + + + handleCopy(command, `${manager}-install`, "Copied!") + } + /> + + ))} + + +
+

+ Then add the component to your layout: +

+ handleCopy(npmCode, "npm-code", "Code copied!")} + /> +
+
+ + +

+ Add this to the{" "} + + {""} + {" "} + of your website: +

+ + handleCopy(trackingCode, "script-tag", "Script tag copied!") + } + /> +
+
+ + {/* Website ID */} +
+ Website ID: + +
+
+ ); +} diff --git a/apps/dashboard/app/(main)/onboarding/_components/step-invite-team.tsx b/apps/dashboard/app/(main)/onboarding/_components/step-invite-team.tsx new file mode 100644 index 000000000..fee0fece1 --- /dev/null +++ b/apps/dashboard/app/(main)/onboarding/_components/step-invite-team.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { track } from "@databuddy/sdk"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + EnvelopeSimpleIcon, + SpinnerIcon, + UsersIcon, +} from "@phosphor-icons/react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { useOrganizationsContext } from "@/components/providers/organizations-provider"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useOrganizationInvitations } from "@/hooks/use-organization-invitations"; + +const formSchema = z.object({ + email: z.string().email("Enter a valid email address"), + role: z.enum(["admin", "member"]), +}); + +type FormData = z.infer; + +interface SentInvite { + email: string; + role: string; +} + +export function StepInviteTeam() { + const { activeOrganization } = useOrganizationsContext(); + const organizationId = activeOrganization?.id ?? ""; + const { inviteMember, isInviting } = + useOrganizationInvitations(organizationId); + const [sentInvites, setSentInvites] = useState([]); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + role: "member", + }, + }); + + const handleSubmit = async (values: FormData) => { + if (!organizationId) { + toast.error("No active organization found."); + return; + } + + try { + await inviteMember({ + email: values.email, + role: values.role, + organizationId, + }); + setSentInvites((prev) => [ + ...prev, + { email: values.email, role: values.role }, + ]); + form.reset(); + try { + track("onboarding_invite_sent", { + role: values.role, + invite_count: sentInvites.length + 1, + }); + } catch {} + } catch { + // Error handled by the hook's toast + } + }; + + return ( +
+
+
+ +
+
+

+ Invite your team +

+

+ Collaborate with your team on analytics insights. +

+
+
+ + {/* Invite form */} +
+ + ( + + + + + + + )} + /> + ( + + + + )} + /> + + + + + {/* Sent invites */} + {sentInvites.length > 0 && ( +
+

+ Invited ({sentInvites.length}) +

+
+ {sentInvites.map((invite) => ( + + + {invite.email} + + ))} +
+
+ )} + +

+ You can always invite more people later from settings. +

+
+ ); +} diff --git a/apps/dashboard/app/(main)/onboarding/page.tsx b/apps/dashboard/app/(main)/onboarding/page.tsx index c1ec832a4..2abb0f4fe 100644 --- a/apps/dashboard/app/(main)/onboarding/page.tsx +++ b/apps/dashboard/app/(main)/onboarding/page.tsx @@ -1,247 +1,252 @@ "use client"; -import { - ArrowRightIcon, - CheckIcon, - CodeIcon, - GlobeIcon, - type Icon, - SparkleIcon, -} from "@phosphor-icons/react"; +import { track } from "@databuddy/sdk"; +import { ArrowLeftIcon, ArrowRightIcon } from "@phosphor-icons/react"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { PageHeader } from "@/app/(main)/websites/_components/page-header"; -import { EmptyState } from "@/components/empty-state"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { WebsiteDialog } from "@/components/website-dialog"; import { useWebsitesLight } from "@/hooks/use-websites"; -import { cn } from "@/lib/utils"; - -interface OnboardingStep { - id: string; - title: string; - description: string; - icon: Icon; - completed: boolean; - action?: () => void; - actionLabel?: string; -} - -function StepIndicator({ - steps, - currentStep, -}: { - steps: OnboardingStep[]; - currentStep: number; -}) { - return ( -
- {steps.map((step, index) => ( -
-
- {step.completed ? ( - - ) : ( - - {index + 1} - - )} -
- {index < steps.length - 1 && ( -
- )} -
- ))} -
- ); -} - -function OnboardingStepCard({ - step, - isActive, -}: { - step: OnboardingStep; - isActive: boolean; -}) { - return ( - -
-
- -
-
-

- {step.title} -

-

- {step.description} -

-
-
- {step.completed ? ( - - ) : isActive && step.action ? ( - - ) : null} -
-
-
- ); +import { OnboardingStepIndicator } from "./_components/onboarding-step-indicator"; +import { StepCreateWebsite } from "./_components/step-create-website"; +import { StepExplore } from "./_components/step-explore"; +import { StepInstallTracking } from "./_components/step-install-tracking"; +import { StepInviteTeam } from "./_components/step-invite-team"; + +const STEPS = [ + { id: "website", title: "Add Website" }, + { id: "tracking", title: "Install Tracking" }, + { id: "team", title: "Invite Team" }, + { id: "explore", title: "Explore" }, +] as const; + +function trackOnboarding( + event: string, + properties?: Record +) { + try { + track(`onboarding_${event}`, properties); + } catch { + // SDK not loaded yet + } } export default function OnboardingPage() { const router = useRouter(); - const [showCreateWebsiteDialog, setShowCreateWebsiteDialog] = useState(false); - const { websites } = useWebsitesLight(); + const trackedStepRef = useRef(-1); - const hasWebsite = Boolean(websites && websites.length > 0); - - const steps: OnboardingStep[] = [ - { - id: "website", - title: "Add Your Website", - description: hasWebsite - ? "Website added successfully" - : "Add your first website to start tracking analytics", - icon: GlobeIcon, - completed: hasWebsite, - action: hasWebsite ? undefined : () => setShowCreateWebsiteDialog(true), - actionLabel: "Add Website", - }, - { - id: "setup", - title: "Install Tracking", - description: "Add the tracking script to your website to collect data", - icon: CodeIcon, - completed: false, - action: () => { - if (websites && websites.length > 0) { - window.location.href = `/websites/${websites[0].id}?tab=tracking-setup`; - } - }, - actionLabel: "Setup Tracking", - }, - ]; + const [currentStep, setCurrentStep] = useState(0); + const [completedSteps, setCompletedSteps] = useState>(new Set()); + const [createdWebsiteId, setCreatedWebsiteId] = useState(null); + + const hasWebsite = websites && websites.length > 0; + const websiteId = createdWebsiteId ?? websites?.[0]?.id ?? ""; + + // Update URL and track step views + useEffect(() => { + const stepId = STEPS[currentStep].id; + window.history.replaceState(null, "", `/onboarding?step=${stepId}`); + + if (trackedStepRef.current !== currentStep) { + trackedStepRef.current = currentStep; + trackOnboarding("step_viewed", { + step: stepId, + step_number: currentStep + 1, + }); + } + }, [currentStep]); - const currentStepIndex = steps.findIndex((step) => !step.completed); - const allCompleted = currentStepIndex === -1; + useEffect(() => { + if (hasWebsite && !completedSteps.has("website")) { + setCompletedSteps((prev) => new Set([...prev, "website"])); + if (currentStep === 0) { + setCurrentStep(1); + } + } + }, [hasWebsite, completedSteps, currentStep]); - // Check for pending plan selection and redirect to billing + // Track onboarding start once useEffect(() => { + trackOnboarding("started"); + }, []); + + const markComplete = useCallback((stepId: string) => { + setCompletedSteps((prev) => new Set([...prev, stepId])); + }, []); + + const goNext = useCallback(() => { + setCurrentStep((prev) => Math.min(prev + 1, STEPS.length - 1)); + }, []); + + const goBack = useCallback(() => { + setCurrentStep((prev) => Math.max(prev - 1, 0)); + }, []); + + const handleWebsiteCreated = useCallback( + (id: string) => { + setCreatedWebsiteId(id); + markComplete("website"); + trackOnboarding("step_completed", { step: "website" }); + goNext(); + }, + [markComplete, goNext] + ); + + const handleTrackingComplete = useCallback(() => { + markComplete("tracking"); + trackOnboarding("step_completed", { + step: "tracking", + verified: true, + }); + goNext(); + }, [markComplete, goNext]); + + const handleTeamComplete = useCallback(() => { + markComplete("team"); + trackOnboarding("step_completed", { step: "team" }); + goNext(); + }, [markComplete, goNext]); + + const handleExploreComplete = useCallback(() => { + markComplete("explore"); + trackOnboarding("completed"); const pendingPlan = localStorage.getItem("pendingPlanSelection"); - if (pendingPlan && allCompleted) { + if (pendingPlan) { localStorage.removeItem("pendingPlanSelection"); - router.push(`/billing?tab=plans&plan=${pendingPlan}`); + router.replace(`/billing?tab=plans&plan=${pendingPlan}`); + } else { + router.replace(`/websites/${websiteId}`); + } + }, [markComplete, router, websiteId]); + + const handleSkipOnboarding = useCallback(() => { + trackOnboarding("skipped", { + skipped_at_step: STEPS[currentStep].id, + step_number: currentStep + 1, + }); + router.push("/websites"); + }, [currentStep, router]); + + const canContinue = useMemo(() => { + const step = STEPS[currentStep]; + switch (step.id) { + case "website": + return completedSteps.has("website"); + case "tracking": + return true; + case "team": + return true; + case "explore": + return true; + default: + return false; } - }, [allCompleted, router]); + }, [currentStep, completedSteps]); + + const handleContinue = useCallback(() => { + const step = STEPS[currentStep]; + if (step.id === "explore") { + handleExploreComplete(); + return; + } + if (step.id === "team") { + handleTeamComplete(); + return; + } + if (step.id === "tracking") { + if (!completedSteps.has("tracking")) { + markComplete("tracking"); + trackOnboarding("step_completed", { + step: "tracking", + verified: false, + }); + } + goNext(); + return; + } + goNext(); + }, [ + currentStep, + completedSteps, + goNext, + markComplete, + handleExploreComplete, + handleTeamComplete, + ]); + + const renderStep = () => { + switch (STEPS[currentStep].id) { + case "website": + return ; + case "tracking": + return ( + + ); + case "team": + return ; + case "explore": + return ( + + ); + default: + return null; + } + }; + + const isFirstStep = currentStep === 0; + const showBottomNav = STEPS[currentStep].id !== "explore"; return (
- } - title="Get Started" - /> - -
-
- {!allCompleted && ( - <> - - -
- {steps.map((step, index) => ( - - ))} -
- -
- -
- - )} - - {allCompleted && ( - { - window.location.href = "/websites"; - }, - }} - className="h-full" - description="You've successfully completed the onboarding. You're ready to start tracking analytics!" - icon={} - showPlusBadge={false} - title="All Set!" - variant="minimal" - /> - )} -
+ {/* Header */} +
+ ({ id: s.id, title: s.title }))} + /> +
- + {/* Content */} +
+
{renderStep()}
+
+ + {/* Bottom nav */} + {showBottomNav && ( +
+ + +
+ )}
); } diff --git a/apps/dashboard/app/(main)/organizations/components/empty-state.tsx b/apps/dashboard/app/(main)/organizations/components/empty-state.tsx index 86ea7b5fc..8bba45f7c 100644 --- a/apps/dashboard/app/(main)/organizations/components/empty-state.tsx +++ b/apps/dashboard/app/(main)/organizations/components/empty-state.tsx @@ -4,13 +4,13 @@ import type { Icon } from "@phosphor-icons/react"; import type { ReactNode } from "react"; interface EmptyStateProps { - icon: Icon; - title: string; + action?: ReactNode; description: string; features?: Array<{ label: string; }>; - action?: ReactNode; + icon: Icon; + title: string; variant?: "default" | "success" | "warning" | "destructive"; } diff --git a/apps/dashboard/app/(main)/organizations/components/general-settings.tsx b/apps/dashboard/app/(main)/organizations/components/general-settings.tsx index 3a14cf3bf..dbbe16bac 100644 --- a/apps/dashboard/app/(main)/organizations/components/general-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/components/general-settings.tsx @@ -1,11 +1,9 @@ "use client"; -import { - BuildingsIcon, - CheckIcon, - CopyIcon, - FloppyDiskIcon, -} from "@phosphor-icons/react"; +import { BuildingsIcon } from "@phosphor-icons/react"; +import { CheckIcon } from "@phosphor-icons/react"; +import { CopyIcon } from "@phosphor-icons/react"; +import { FloppyDiskIcon } from "@phosphor-icons/react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { RightSidebar } from "@/components/right-sidebar"; diff --git a/apps/dashboard/app/(main)/organizations/components/list-skeleton.tsx b/apps/dashboard/app/(main)/organizations/components/list-skeleton.tsx index a47f70f40..089a304bd 100644 --- a/apps/dashboard/app/(main)/organizations/components/list-skeleton.tsx +++ b/apps/dashboard/app/(main)/organizations/components/list-skeleton.tsx @@ -4,8 +4,8 @@ import { Skeleton } from "@/components/ui/skeleton"; interface ListSkeletonProps { count?: number; - showAvatar?: boolean; showActions?: boolean; + showAvatar?: boolean; } export function ListSkeleton({ diff --git a/apps/dashboard/app/(main)/organizations/components/organization-avatar-editor.tsx b/apps/dashboard/app/(main)/organizations/components/organization-avatar-editor.tsx index 4fb5ac7f0..a2fb935a0 100644 --- a/apps/dashboard/app/(main)/organizations/components/organization-avatar-editor.tsx +++ b/apps/dashboard/app/(main)/organizations/components/organization-avatar-editor.tsx @@ -1,6 +1,7 @@ "use client"; -import { ArrowsClockwiseIcon, PencilSimpleIcon } from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; +import { PencilSimpleIcon } from "@phosphor-icons/react"; import { nanoid } from "nanoid"; import { useState } from "react"; import { toast } from "sonner"; diff --git a/apps/dashboard/app/(main)/organizations/components/organization-provider.tsx b/apps/dashboard/app/(main)/organizations/components/organization-provider.tsx index 524239116..5a114b568 100644 --- a/apps/dashboard/app/(main)/organizations/components/organization-provider.tsx +++ b/apps/dashboard/app/(main)/organizations/components/organization-provider.tsx @@ -1,15 +1,13 @@ "use client"; import type { Icon as PhosphorIcon } from "@phosphor-icons/react"; -import { - BuildingsIcon, - EnvelopeIcon, - GearIcon, - GlobeIcon, - KeyIcon, - UsersIcon, - WarningIcon, -} from "@phosphor-icons/react"; +import { BuildingsIcon } from "@phosphor-icons/react"; +import { EnvelopeIcon } from "@phosphor-icons/react"; +import { GearIcon } from "@phosphor-icons/react"; +import { GlobeIcon } from "@phosphor-icons/react"; +import { KeyIcon } from "@phosphor-icons/react"; +import { UsersIcon } from "@phosphor-icons/react"; +import { WarningIcon } from "@phosphor-icons/react"; import { useAtomValue } from "jotai"; import { usePathname } from "next/navigation"; import { useMemo, useState } from "react"; @@ -25,18 +23,18 @@ import { } from "@/stores/jotai/organizationsAtoms"; interface HeaderActionButton { - text: string; - icon: PhosphorIcon; action: () => void; disabled?: boolean; + icon: PhosphorIcon; + text: string; } interface PageInfo { - title: string; + actionButton?: HeaderActionButton; description: string; icon: PhosphorIcon; requiresOrg?: boolean; - actionButton?: HeaderActionButton; + title: string; } const PAGE_INFO_MAP: Record = { diff --git a/apps/dashboard/app/(main)/organizations/components/organizations-list.tsx b/apps/dashboard/app/(main)/organizations/components/organizations-list.tsx index c9eff904d..cba0d11b9 100644 --- a/apps/dashboard/app/(main)/organizations/components/organizations-list.tsx +++ b/apps/dashboard/app/(main)/organizations/components/organizations-list.tsx @@ -1,12 +1,10 @@ "use client"; import { authClient } from "@databuddy/auth/client"; -import { - BuildingsIcon, - CaretRightIcon, - CheckCircleIcon, - PlusIcon, -} from "@phosphor-icons/react"; +import { BuildingsIcon } from "@phosphor-icons/react"; +import { CaretRightIcon } from "@phosphor-icons/react"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { PlusIcon } from "@phosphor-icons/react"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -28,8 +26,8 @@ function getDicebearUrl(seed: string): string { } interface OrganizationsListProps { - organizations: Organization[] | null | undefined; activeOrganization: Organization | null | undefined; + organizations: Organization[] | null | undefined; } function EmptyState() { @@ -60,10 +58,10 @@ function EmptyState() { } interface OrganizationRowProps { - organization: Organization; isActive: boolean; isProcessing: boolean; onClick: () => void; + organization: Organization; } function OrganizationRow({ diff --git a/apps/dashboard/app/(main)/organizations/invitations/invitation-list.tsx b/apps/dashboard/app/(main)/organizations/invitations/invitation-list.tsx index ee8fcf0b8..e84e09e4c 100644 --- a/apps/dashboard/app/(main)/organizations/invitations/invitation-list.tsx +++ b/apps/dashboard/app/(main)/organizations/invitations/invitation-list.tsx @@ -1,6 +1,7 @@ "use client"; -import { EnvelopeIcon, TrashIcon } from "@phosphor-icons/react"; +import { EnvelopeIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -10,8 +11,8 @@ import dayjs from "@/lib/dayjs"; import { cn } from "@/lib/utils"; interface InvitationToCancel { - id: string; email: string; + id: string; } interface InvitationRowProps { diff --git a/apps/dashboard/app/(main)/organizations/invitations/invitations-view.tsx b/apps/dashboard/app/(main)/organizations/invitations/invitations-view.tsx index e0b913f0d..ed674a5ef 100644 --- a/apps/dashboard/app/(main)/organizations/invitations/invitations-view.tsx +++ b/apps/dashboard/app/(main)/organizations/invitations/invitations-view.tsx @@ -1,13 +1,11 @@ "use client"; -import { - ArrowClockwiseIcon, - CheckIcon, - ClockIcon, - EnvelopeIcon, - UserPlusIcon, - XIcon, -} from "@phosphor-icons/react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { CheckIcon } from "@phosphor-icons/react"; +import { ClockIcon } from "@phosphor-icons/react"; +import { EnvelopeIcon } from "@phosphor-icons/react"; +import { UserPlusIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { EmptyState } from "@/components/empty-state"; import { InviteMemberDialog } from "@/components/organizations/invite-member-dialog"; diff --git a/apps/dashboard/app/(main)/organizations/members/member-list.tsx b/apps/dashboard/app/(main)/organizations/members/member-list.tsx index 074d50a54..02be8db1a 100644 --- a/apps/dashboard/app/(main)/organizations/members/member-list.tsx +++ b/apps/dashboard/app/(main)/organizations/members/member-list.tsx @@ -1,7 +1,8 @@ "use client"; import { authClient } from "@databuddy/auth/client"; -import { CrownIcon, TrashIcon } from "@phosphor-icons/react"; +import { CrownIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -26,21 +27,21 @@ interface MemberToRemove { } interface MemberListProps { + isRemovingMember: boolean; + isUpdatingMember: boolean; members: OrganizationMember[]; onRemoveMember: (memberId: string) => void; - isRemovingMember: boolean; onUpdateRole: (member: UpdateMemberData) => void; - isUpdatingMember: boolean; organizationId: string; } interface RoleSelectorProps { + canEditRoles: boolean; + isCurrentUser: boolean; + isUpdatingMember: boolean; member: OrganizationMember; onUpdateRole: MemberListProps["onUpdateRole"]; - isUpdatingMember: boolean; organizationId: string; - canEditRoles: boolean; - isCurrentUser: boolean; } function RoleSelector({ @@ -92,15 +93,15 @@ function RoleSelector({ } interface MemberRowProps { + canEditRoles: boolean; + isCurrentUser: boolean; + isRemovingMember: boolean; + isUpdatingMember: boolean; member: OrganizationMember; + onConfirmRemove: (member: MemberToRemove) => void; onRemoveMember: MemberListProps["onRemoveMember"]; - isRemovingMember: boolean; onUpdateRole: MemberListProps["onUpdateRole"]; - isUpdatingMember: boolean; organizationId: string; - onConfirmRemove: (member: MemberToRemove) => void; - canEditRoles: boolean; - isCurrentUser: boolean; } function MemberRow({ diff --git a/apps/dashboard/app/(main)/organizations/members/members-view.tsx b/apps/dashboard/app/(main)/organizations/members/members-view.tsx index b75801f2f..a1d316511 100644 --- a/apps/dashboard/app/(main)/organizations/members/members-view.tsx +++ b/apps/dashboard/app/(main)/organizations/members/members-view.tsx @@ -1,10 +1,8 @@ "use client"; -import { - ArrowClockwiseIcon, - UserPlusIcon, - UsersIcon, -} from "@phosphor-icons/react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { UserPlusIcon } from "@phosphor-icons/react"; +import { UsersIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { InviteMemberDialog } from "@/components/organizations/invite-member-dialog"; import { RightSidebar } from "@/components/right-sidebar"; diff --git a/apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-row.tsx b/apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-row.tsx index 17c963744..fd3e89ef6 100644 --- a/apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-row.tsx +++ b/apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-row.tsx @@ -1,12 +1,10 @@ "use client"; -import { - CalendarIcon, - CaretRightIcon, - KeyIcon, - LockKeyIcon, - WarningIcon, -} from "@phosphor-icons/react"; +import { CalendarIcon } from "@phosphor-icons/react"; +import { CaretRightIcon } from "@phosphor-icons/react"; +import { KeyIcon } from "@phosphor-icons/react"; +import { LockKeyIcon } from "@phosphor-icons/react"; +import { WarningIcon } from "@phosphor-icons/react"; import type { ApiKeyListItem } from "@/components/organizations/api-key-types"; import { Badge } from "@/components/ui/badge"; import dayjs from "@/lib/dayjs"; diff --git a/apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-settings.tsx b/apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-settings.tsx index 8a5e2b196..4c753a2e9 100644 --- a/apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/settings/api-keys/api-key-settings.tsx @@ -1,6 +1,8 @@ "use client"; -import { KeyIcon, PlusIcon, ShieldCheckIcon } from "@phosphor-icons/react"; +import { KeyIcon } from "@phosphor-icons/react"; +import { PlusIcon } from "@phosphor-icons/react"; +import { ShieldCheckIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { EmptyState } from "@/components/empty-state"; diff --git a/apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx b/apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx index e97e4e7d9..476651a46 100644 --- a/apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/settings/danger/danger-zone-settings.tsx @@ -1,7 +1,9 @@ "use client"; import { authClient } from "@databuddy/auth/client"; -import { SignOutIcon, TrashIcon, WarningIcon } from "@phosphor-icons/react"; +import { SignOutIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; +import { WarningIcon } from "@phosphor-icons/react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; diff --git a/apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx b/apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx index 77736e814..cfe130228 100644 --- a/apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx +++ b/apps/dashboard/app/(main)/organizations/settings/danger/transfer-assets.tsx @@ -1,11 +1,9 @@ "use client"; -import { - ArrowRightIcon, - ArrowsLeftRightIcon, - BuildingsIcon, - GlobeIcon, -} from "@phosphor-icons/react"; +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { ArrowsLeftRightIcon } from "@phosphor-icons/react"; +import { BuildingsIcon } from "@phosphor-icons/react"; +import { GlobeIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { toast } from "sonner"; import { FaviconImage } from "@/components/analytics/favicon-image"; @@ -24,9 +22,9 @@ import { cn } from "@/lib/utils"; import { useWebsiteTransfer } from "./hooks/use-website-transfer"; interface WebsiteItemProps { - website: Website; - selected: boolean; onClickAction: () => void; + selected: boolean; + website: Website; } function WebsiteItem({ website, selected, onClickAction }: WebsiteItemProps) { diff --git a/apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx b/apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx index 3346bbef6..a3dd40389 100644 --- a/apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/settings/websites/website-settings.tsx @@ -1,11 +1,9 @@ "use client"; -import { - ArrowClockwiseIcon, - CaretRightIcon, - GlobeIcon, - PlusIcon, -} from "@phosphor-icons/react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { CaretRightIcon } from "@phosphor-icons/react"; +import { GlobeIcon } from "@phosphor-icons/react"; +import { PlusIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useState } from "react"; diff --git a/apps/dashboard/app/(main)/settings/_components/settings-section.tsx b/apps/dashboard/app/(main)/settings/_components/settings-section.tsx index 427617254..9b2071c76 100644 --- a/apps/dashboard/app/(main)/settings/_components/settings-section.tsx +++ b/apps/dashboard/app/(main)/settings/_components/settings-section.tsx @@ -4,10 +4,10 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface SettingsSectionProps { - title: string; - description?: string; children: React.ReactNode; className?: string; + description?: string; + title: string; } export function SettingsSection({ @@ -30,10 +30,10 @@ export function SettingsSection({ } interface SettingsRowProps { - label: React.ReactNode; - description?: string; children: React.ReactNode; className?: string; + description?: string; + label: React.ReactNode; } export function SettingsRow({ @@ -61,9 +61,9 @@ export function SettingsRow({ } interface ComingSoonProps { - title: string; description: string; icon: React.ReactNode; + title: string; } export function ComingSoon({ title, description, icon }: ComingSoonProps) { @@ -83,10 +83,10 @@ export function ComingSoon({ title, description, icon }: ComingSoonProps) { interface UnsavedChangesFooterProps { hasChanges: boolean; isSaving: boolean; - onSave: () => void; + message?: string; onDiscard?: () => void; + onSave: () => void; saveLabel?: string; - message?: string; } export function UnsavedChangesFooter({ diff --git a/apps/dashboard/app/(main)/settings/account/page.tsx b/apps/dashboard/app/(main)/settings/account/page.tsx index 190707cba..1b59162a9 100644 --- a/apps/dashboard/app/(main)/settings/account/page.tsx +++ b/apps/dashboard/app/(main)/settings/account/page.tsx @@ -2,15 +2,13 @@ import { authClient } from "@databuddy/auth/client"; import type { Icon } from "@phosphor-icons/react"; -import { - CircleNotchIcon, - GithubLogoIcon, - GoogleLogoIcon, - KeyIcon, - LinkBreakIcon, - LinkIcon, - ShieldCheckIcon, -} from "@phosphor-icons/react"; +import { CircleNotchIcon } from "@phosphor-icons/react"; +import { GithubLogoIcon } from "@phosphor-icons/react"; +import { GoogleLogoIcon } from "@phosphor-icons/react"; +import { KeyIcon } from "@phosphor-icons/react"; +import { LinkBreakIcon } from "@phosphor-icons/react"; +import { LinkIcon } from "@phosphor-icons/react"; +import { ShieldCheckIcon } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -36,10 +34,10 @@ import { TwoFactorDialog } from "./sections/two-factor-dialog"; // Types interface Account { - id: string; - providerId: string; accountId: string; createdAt: Date; + id: string; + providerId: string; } type SocialProvider = "google" | "github"; diff --git a/apps/dashboard/app/(main)/settings/account/sections/two-factor-dialog.tsx b/apps/dashboard/app/(main)/settings/account/sections/two-factor-dialog.tsx index ba1582b84..8ea34e50a 100644 --- a/apps/dashboard/app/(main)/settings/account/sections/two-factor-dialog.tsx +++ b/apps/dashboard/app/(main)/settings/account/sections/two-factor-dialog.tsx @@ -1,16 +1,14 @@ "use client"; import { authClient } from "@databuddy/auth/client"; -import { - CaretDownIcon, - CheckCircleIcon, - CircleNotchIcon, - CopyIcon, - DeviceMobileIcon, - KeyIcon, - ShieldCheckIcon, - WarningCircleIcon, -} from "@phosphor-icons/react"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { CheckCircleIcon } from "@phosphor-icons/react"; +import { CircleNotchIcon } from "@phosphor-icons/react"; +import { CopyIcon } from "@phosphor-icons/react"; +import { DeviceMobileIcon } from "@phosphor-icons/react"; +import { KeyIcon } from "@phosphor-icons/react"; +import { ShieldCheckIcon } from "@phosphor-icons/react"; +import { WarningCircleIcon } from "@phosphor-icons/react"; import { useMutation } from "@tanstack/react-query"; import { QRCodeSVG } from "qrcode.react"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -43,11 +41,11 @@ type TwoFactorStep = | "manage"; interface TwoFactorDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - isEnabled: boolean; hasCredentialAccount: boolean; + isEnabled: boolean; + onOpenChange: (open: boolean) => void; onSuccess: () => void; + open: boolean; } const MIN_PASSWORD_LENGTH = 8; diff --git a/apps/dashboard/app/(main)/settings/appearance/page.tsx b/apps/dashboard/app/(main)/settings/appearance/page.tsx index e4a3a2f9f..162c8159b 100644 --- a/apps/dashboard/app/(main)/settings/appearance/page.tsx +++ b/apps/dashboard/app/(main)/settings/appearance/page.tsx @@ -1,21 +1,19 @@ "use client"; -import { - CalendarBlankIcon, - CaretDownIcon, - ChartBarIcon, - ChartLineIcon, - ClockIcon, - CursorClickIcon, - DesktopIcon, - FunnelIcon, - MoonIcon, - PresentationChartIcon, - SquaresFourIcon, - StackIcon, - SunIcon, - UsersIcon, -} from "@phosphor-icons/react"; +import { CalendarBlankIcon } from "@phosphor-icons/react"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { ChartBarIcon } from "@phosphor-icons/react"; +import { ChartLineIcon } from "@phosphor-icons/react"; +import { ClockIcon } from "@phosphor-icons/react"; +import { CursorClickIcon } from "@phosphor-icons/react"; +import { DesktopIcon } from "@phosphor-icons/react"; +import { FunnelIcon } from "@phosphor-icons/react"; +import { MoonIcon } from "@phosphor-icons/react"; +import { PresentationChartIcon } from "@phosphor-icons/react"; +import { SquaresFourIcon } from "@phosphor-icons/react"; +import { StackIcon } from "@phosphor-icons/react"; +import { SunIcon } from "@phosphor-icons/react"; +import { UsersIcon } from "@phosphor-icons/react"; import { useTheme } from "next-themes"; import { useState } from "react"; import { StatCard } from "@/components/analytics/stat-card"; diff --git a/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx b/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx index a5cf8d029..e3744858d 100644 --- a/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx +++ b/apps/dashboard/app/(main)/settings/notifications/_components/alarm-sheet.tsx @@ -1,7 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PlusIcon, TrashIcon } from "@phosphor-icons/react"; +import { PlusIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { useFieldArray, useForm } from "react-hook-form"; @@ -75,28 +76,28 @@ const alarmFormSchema = z.object({ type AlarmFormData = z.infer; interface AlarmDestination { + config: Record; id: string; - type: string; identifier: string; - config: Record; + type: string; } export interface AlarmData { - id: string; - name: string; description?: string | null; + destinations?: AlarmDestination[]; enabled: boolean; - websiteId?: string | null; - triggerType: string; + id: string; + name: string; triggerConditions?: Record; - destinations?: AlarmDestination[]; + triggerType: string; + websiteId?: string | null; } interface AlarmSheetProps { - open: boolean; + alarm?: AlarmData | null; onCloseAction: (open: boolean) => void; onSaveAction?: () => void; - alarm?: AlarmData | null; + open: boolean; } const IDENTIFIER_LABELS: Record = { diff --git a/apps/dashboard/app/(main)/settings/notifications/page.tsx b/apps/dashboard/app/(main)/settings/notifications/page.tsx index 1e15a2a7a..6db83eeb4 100644 --- a/apps/dashboard/app/(main)/settings/notifications/page.tsx +++ b/apps/dashboard/app/(main)/settings/notifications/page.tsx @@ -1,14 +1,12 @@ "use client"; -import { - BellIcon, - CircleNotchIcon, - DotsThreeIcon, - PencilIcon, - PlusIcon, - TestTubeIcon, - TrashIcon, -} from "@phosphor-icons/react"; +import { BellIcon } from "@phosphor-icons/react"; +import { CircleNotchIcon } from "@phosphor-icons/react"; +import { DotsThreeIcon } from "@phosphor-icons/react"; +import { PencilIcon } from "@phosphor-icons/react"; +import { PlusIcon } from "@phosphor-icons/react"; +import { TestTubeIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; @@ -29,20 +27,20 @@ import { orpc } from "@/lib/orpc"; import { AlarmSheet } from "./_components/alarm-sheet"; interface AlarmDestination { + config: Record; id: string; - type: string; identifier: string; - config: Record; + type: string; } interface Alarm { - id: string; - name: string; description?: string | null; + destinations?: AlarmDestination[]; enabled: boolean; + id: string; + name: string; triggerType: string; websiteId?: string | null; - destinations?: AlarmDestination[]; } const DEST_LABELS: Record = { diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/analytics-toolbar.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/analytics-toolbar.tsx index 679323233..da6ef107c 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/analytics-toolbar.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/analytics-toolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowClockwiseIcon } from "@phosphor-icons/react/dist/ssr/ArrowClockwise"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; import clsx from "clsx"; import { useAtom } from "jotai"; import { useCallback, useMemo } from "react"; @@ -18,10 +18,10 @@ import { FiltersSection } from "./filters/filters-section"; const MAX_HOURLY_DAYS = 7; interface QuickRange { - label: string; + days?: number; fullLabel: string; hours?: number; - days?: number; + label: string; } const QUICK_RANGES: QuickRange[] = [ diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx index 5266a2e7b..c9b46df79 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/filters/add-filters.tsx @@ -3,11 +3,9 @@ import { filterOptions } from "@databuddy/shared/lists/filters"; import type { DynamicQueryFilter } from "@databuddy/shared/types/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { - ArrowLeftIcon, - FunnelIcon, - WarningCircleIcon, -} from "@phosphor-icons/react"; +import { ArrowLeftIcon } from "@phosphor-icons/react"; +import { FunnelIcon } from "@phosphor-icons/react"; +import { WarningCircleIcon } from "@phosphor-icons/react"; import { useParams } from "next/navigation"; import { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx index f2af0aebb..73ea17d13 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/filters/filters-section.tsx @@ -2,9 +2,9 @@ import { filterOptions } from "@databuddy/shared/lists/filters"; import type { DynamicQueryFilter } from "@databuddy/shared/types/api"; -import { FloppyDiskIcon } from "@phosphor-icons/react/dist/ssr/FloppyDisk"; -import { PencilIcon } from "@phosphor-icons/react/dist/ssr/Pencil"; -import { XIcon } from "@phosphor-icons/react/dist/ssr/X"; +import { FloppyDiskIcon } from "@phosphor-icons/react"; +import { PencilIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; import { useAtom } from "jotai"; import { useParams } from "next/navigation"; import { useCallback, useState } from "react"; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx index 8e2602b60..6887fb1d6 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/filters/save-filter-dialog.tsx @@ -3,7 +3,7 @@ import { filterOptions } from "@databuddy/shared/lists/filters"; import type { DynamicQueryFilter } from "@databuddy/shared/types/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { FloppyDiskIcon } from "@phosphor-icons/react/dist/ssr/FloppyDisk"; +import { FloppyDiskIcon } from "@phosphor-icons/react"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -38,16 +38,16 @@ type EditingFilter = { } | null; interface SaveFilterDialogProps { + editingFilter?: EditingFilter; + filters: DynamicQueryFilter[]; + isLoading?: boolean; isOpen: boolean; onClose: () => void; onSave: (name: string) => void; - filters: DynamicQueryFilter[]; - isLoading?: boolean; validateName?: ( name: string, excludeId?: string ) => { type: string; message: string } | null; - editingFilter?: EditingFilter; } export function SaveFilterDialog({ diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx index b09a59386..c7d08f587 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/filters/saved-filters-menu.tsx @@ -2,13 +2,11 @@ import { filterOptions } from "@databuddy/shared/lists/filters"; import type { DynamicQueryFilter } from "@databuddy/shared/types/api"; -import { - BookmarkIcon, - CheckIcon, - CopyIcon, - PencilIcon, - TrashIcon, -} from "@phosphor-icons/react"; +import { BookmarkIcon } from "@phosphor-icons/react"; +import { CheckIcon } from "@phosphor-icons/react"; +import { CopyIcon } from "@phosphor-icons/react"; +import { PencilIcon } from "@phosphor-icons/react"; +import { TrashIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -22,14 +20,14 @@ import { getOperatorLabel } from "@/hooks/use-filters"; import type { SavedFilter } from "@/hooks/use-saved-filters"; interface SavedFiltersMenuProps { - savedFilters: SavedFilter[]; + currentFilters: DynamicQueryFilter[]; isLoading: boolean; onApplyFilter: (filters: DynamicQueryFilter[]) => void; + onDeleteAll: () => void; onDeleteFilter: (id: string) => void; onDuplicateFilter: (id: string) => void; onEditFilter: (id: string) => void; - onDeleteAll: () => void; - currentFilters: DynamicQueryFilter[]; + savedFilters: SavedFilter[]; } function getFieldLabel(field: string): string { diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx index 1f3071412..507d39808 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/audience-tab.tsx @@ -1,14 +1,13 @@ "use client"; -import { - DeviceMobileIcon, - DeviceTabletIcon, - LaptopIcon, - MonitorIcon, -} from "@phosphor-icons/react"; +import { DeviceMobileIcon } from "@phosphor-icons/react"; +import { DeviceTabletIcon } from "@phosphor-icons/react"; +import { LaptopIcon } from "@phosphor-icons/react"; +import { MonitorIcon } from "@phosphor-icons/react"; import type { CellContext, ColumnDef } from "@tanstack/react-table"; import { useCallback, useMemo } from "react"; import { ErrorBoundary } from "@/components/error-boundary"; +import { formatNumber } from "@/lib/formatters"; import { BrowserIcon } from "@/components/icon"; import { DataTable } from "@/components/table/data-table"; import { @@ -21,38 +20,28 @@ import { PercentageBadge } from "../utils/technology-helpers"; import type { FullTabProps } from "../utils/types"; interface BrowserVersion { - version: string; - visitors: number; pageviews: number; percentage?: number; + version: string; + visitors: number; } interface BrowserEntry { - name: string; browserName: string; - visitors: number; + name: string; pageviews: number; percentage: number; versions: BrowserVersion[]; + visitors: number; } interface ScreenResolutionEntry { name: string; - visitors: number; pageviews?: number; percentage?: number; + visitors: number; } -const formatNumber = (value: number | null | undefined): string => { - if (value == null || Number.isNaN(value)) { - return "0"; - } - return Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); -}; - const getGradientConfig = (percentage: number) => { if (percentage >= 40) { return { diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx index 399004f28..812c969b6 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx @@ -1,14 +1,15 @@ "use client"; -import { ChartLineIcon } from "@phosphor-icons/react/dist/ssr/ChartLine"; -import { CursorIcon } from "@phosphor-icons/react/dist/ssr/Cursor"; -import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe"; -import { TimerIcon } from "@phosphor-icons/react/dist/ssr/Timer"; -import { UsersIcon } from "@phosphor-icons/react/dist/ssr/Users"; +import { ChartLineIcon } from "@phosphor-icons/react"; +import { CursorIcon } from "@phosphor-icons/react"; +import { GlobeIcon } from "@phosphor-icons/react"; +import { TimerIcon } from "@phosphor-icons/react"; +import { UsersIcon } from "@phosphor-icons/react"; import type { ColumnDef } from "@tanstack/react-table"; import { useAtom } from "jotai"; import dynamic from "next/dynamic"; import { useCallback, useMemo } from "react"; +import { formatNumber } from "@/lib/formatters"; import { DeviceTypeCell, EventLimitIndicator, @@ -51,23 +52,23 @@ const OutboundLinksSection = dynamic(() => ); interface ChartDataPoint { + bounce_rate?: number; date: string; - rawDate?: string; + median_session_duration?: number; pageviews?: number; - visitors?: number; + rawDate?: string; sessions?: number; - bounce_rate?: number; - median_session_duration?: number; + visitors?: number; [key: string]: unknown; } interface TechnologyData { + category?: string; + icon?: string; name: string; - visitors: number; pageviews?: number; percentage: number; - icon?: string; - category?: string; + visitors: number; } interface CellInfo { @@ -77,17 +78,17 @@ interface CellInfo { interface PageRowData { name: string; - visitors: number; pageviews: number; percentage: number; + visitors: number; } interface AnalyticsRowData { name: string; - visitors: number; pageviews: number; percentage: number; referrer?: string; + visitors: number; } const MIN_PREVIOUS_SESSIONS_FOR_TREND = 5; @@ -503,19 +504,6 @@ export function WebsiteOverviewTab({ ); }; - const formatNumber = useCallback( - (value: number | null | undefined): string => { - if (value === null || value === undefined || Number.isNaN(value)) { - return "0"; - } - return Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); - }, - [] - ); - const pagesTabs = useMemo( () => [ { @@ -595,7 +583,7 @@ export function WebsiteOverviewTab({ cell: createPercentageCell(), }, ], - [formatNumber] + [] ); const browserColumns = useMemo( @@ -635,7 +623,7 @@ export function WebsiteOverviewTab({ cell: createPercentageCell(), }, ], - [formatNumber] + [] ); const osColumns = useMemo( @@ -675,7 +663,7 @@ export function WebsiteOverviewTab({ cell: createPercentageCell(), }, ], - [formatNumber] + [] ); const todayDate = dayjs().format("YYYY-MM-DD"); diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/geo-map-section.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/geo-map-section.tsx index 1dde224a3..2e330fe50 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/geo-map-section.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/geo-map-section.tsx @@ -1,10 +1,11 @@ "use client"; import type { LocationData } from "@databuddy/shared/types/website"; -import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe"; +import { GlobeIcon } from "@phosphor-icons/react"; import dynamic from "next/dynamic"; import { useMemo } from "react"; import { CountryFlag } from "@/components/icon"; +import { formatNumber } from "@/lib/formatters"; import { Skeleton } from "@/components/ui/skeleton"; const MapComponent = dynamic( @@ -26,10 +27,10 @@ const MapComponent = dynamic( ); interface CountryDataItem { - name: string; country_code?: string; - visitors: number; + name: string; pageviews: number; + visitors: number; } interface GeoMapSectionProps { @@ -37,16 +38,6 @@ interface GeoMapSectionProps { isLoading: boolean; } -function formatNumber(value: number): string { - if (value == null || Number.isNaN(value)) { - return "0"; - } - return Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); -} - export function GeoMapSection({ countries, isLoading }: GeoMapSectionProps) { const locationData = useMemo(() => { const processedCountries = (countries || []).map((item) => ({ diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/outbound-links-section.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/outbound-links-section.tsx index 87e1d5fe2..a39b55919 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/outbound-links-section.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/outbound-links-section.tsx @@ -3,6 +3,7 @@ import type { CellContext, ColumnDef } from "@tanstack/react-table"; import { useMemo } from "react"; import { DataTable, type TabConfig } from "@/components/table/data-table"; +import { formatNumber } from "@/lib/formatters"; import { PercentageBadge } from "@/components/ui/percentage-badge"; import type { OutboundDomainRow, @@ -12,16 +13,6 @@ import type { const PROTOCOL_REGEX = /^https?:\/\//; -const formatNumber = (value: number): string => { - if (value == null || Number.isNaN(value)) { - return "0"; - } - return Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); -}; - const createDomainIndicator = () => (
); diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx index 4eb835b35..51b3b5125 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview/_components/traffic-trends-chart.tsx @@ -1,9 +1,12 @@ "use client"; -import { EyeIcon, EyeSlashIcon, NoteIcon, XIcon } from "@phosphor-icons/react"; -import { ChartLineIcon } from "@phosphor-icons/react/dist/ssr/ChartLine"; -import { WarningIcon } from "@phosphor-icons/react/dist/ssr/Warning"; -import { WarningCircleIcon } from "@phosphor-icons/react/dist/ssr/WarningCircle"; +import { EyeIcon } from "@phosphor-icons/react"; +import { EyeSlashIcon } from "@phosphor-icons/react"; +import { NoteIcon } from "@phosphor-icons/react"; +import { XIcon } from "@phosphor-icons/react"; +import { ChartLineIcon } from "@phosphor-icons/react"; +import { WarningIcon } from "@phosphor-icons/react"; +import { WarningCircleIcon } from "@phosphor-icons/react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { useMemo, useState } from "react"; @@ -70,18 +73,18 @@ const { } = Chart.Recharts; interface TooltipPayloadEntry { - dataKey: string; - value: number; color: string; + dataKey: string; payload: Record; + value: number; } interface TooltipProps { active?: boolean; - payload?: TooltipPayloadEntry[]; - label?: string; isDragging?: boolean; justFinishedDragging?: boolean; + label?: string; + payload?: TooltipPayloadEntry[]; } const CustomTooltip = ({ @@ -143,8 +146,8 @@ const CustomTooltip = ({ }; interface DateRangeState { - startDate: Date; endDate: Date; + startDate: Date; } interface CreateAnnotationInput { @@ -917,14 +920,14 @@ export function TrafficTrendsRechartsPlot({ } interface TrafficTrendsChartProps { - websiteId: string; - dateRange: DateRange; chartData: ChartDataRow[]; dateDiff: number; + dateRange: DateRange; isError: boolean; isLoading: boolean; isMobile: boolean; onRangeSelect: (range: { startDate: Date; endDate: Date }) => void; + websiteId: string; } export function TrafficTrendsChart({ diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-metric-cell.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-metric-cell.tsx index 7e5b0e916..a00510331 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-metric-cell.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_components/web-vitals-metric-cell.tsx @@ -1,12 +1,12 @@ "use client"; -import { CheckCircleIcon as CheckCircle } from "@phosphor-icons/react/dist/ssr/CheckCircle"; -import { WarningIcon as Warning } from "@phosphor-icons/react/dist/ssr/Warning"; +import { CheckCircleIcon as CheckCircle } from "@phosphor-icons/react"; +import { WarningIcon as Warning } from "@phosphor-icons/react"; import { formatPerformanceTime } from "../_utils/performance-utils"; interface WebVitalsMetricCellProps { - value?: number; metric: "lcp" | "fcp" | "fid" | "inp" | "cls"; + value?: number; } const getWebVitalsThresholds = (metric: string) => { diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts index 131a112a1..1aeb05aa9 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/performance/_utils/performance-utils.ts @@ -11,16 +11,6 @@ export const formatPerformanceTime = (value: number): string => { : `${seconds.toFixed(1)}s`; }; -export const formatNumber = (value: number | null | undefined): string => { - if (value == null || Number.isNaN(value)) { - return "0"; - } - return Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, - }).format(value); -}; - export const getPerformanceRating = ( score: number ): { rating: string; className: string } => { diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/tracking-setup-tab.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/tracking-setup-tab.tsx index 82ff19acd..7f5aac44c 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/tabs/tracking-setup-tab.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/tabs/tracking-setup-tab.tsx @@ -1,19 +1,17 @@ "use client"; -import { - ArrowClockwiseIcon, - BookOpenIcon, - BugIcon, - CaretDownIcon, - CheckIcon, - ClipboardIcon, - CodeIcon, - GearIcon, - LightningIcon, - PackageIcon, - PulseIcon, - WarningCircleIcon, -} from "@phosphor-icons/react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { BookOpenIcon } from "@phosphor-icons/react"; +import { BugIcon } from "@phosphor-icons/react"; +import { CaretDownIcon } from "@phosphor-icons/react"; +import { CheckIcon } from "@phosphor-icons/react"; +import { ClipboardIcon } from "@phosphor-icons/react"; +import { CodeIcon } from "@phosphor-icons/react"; +import { GearIcon } from "@phosphor-icons/react"; +import { LightningIcon } from "@phosphor-icons/react"; +import { PackageIcon } from "@phosphor-icons/react"; +import { PulseIcon } from "@phosphor-icons/react"; +import { WarningCircleIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { useMemo, useState } from "react"; diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/analytics-helpers.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/utils/analytics-helpers.tsx index 4cab0d2f8..6bbaea740 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/analytics-helpers.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/analytics-helpers.tsx @@ -16,9 +16,9 @@ interface DataItem { } interface ChartDataPoint { + color?: string; name: string; value: number; - color?: string; } // Helper to handle generic data refresh diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tab-layout.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/utils/tab-layout.tsx index a9a9c50e7..1d51aa8fb 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/tab-layout.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/tab-layout.tsx @@ -3,12 +3,12 @@ import { Skeleton } from "@/components/ui/skeleton"; import { BORDER_RADIUS } from "./ui-components"; interface TabLayoutProps { - title?: string; - description?: string; - isLoading?: boolean; + actions?: React.ReactNode; children: React.ReactNode; className?: string; - actions?: React.ReactNode; + description?: string; + isLoading?: boolean; + title?: string; } export function TabLayout({ diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx index 2a6919316..465ccbae4 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/technology-helpers.tsx @@ -17,28 +17,28 @@ const MOBILE_SUFFIX_REGEX = /\s+Mobile$/; // Types export interface DeviceTypeEntry { - device_type: string; device_brand?: string; device_model?: string; - visitors: number; + device_type: string; pageviews?: number; + visitors: number; } export interface BrowserVersionEntry { browser: string; + count?: number; + pageviews?: number; version?: string; visitors: number; - pageviews?: number; - count?: number; } export interface TechnologyTableEntry { - name: string; - visitors: number; - percentage: number; + category?: string; icon?: string; iconComponent?: React.ReactNode; - category?: string; + name: string; + percentage: number; + visitors: number; } export const getDeviceTypeIcon = ( diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts index a0abbe701..d020c9b84 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/types.ts @@ -2,15 +2,15 @@ import type { useWebsite } from "@/hooks/use-websites"; import type { DynamicQueryFilter } from "@/stores/jotai/filterAtoms"; export interface DateRange { - start_date: string; end_date: string; granularity?: "hourly" | "daily"; + start_date: string; timezone?: string; } export interface BaseTabProps { - websiteId: string; dateRange: DateRange; + websiteId: string; } export type WebsiteData = ReturnType["data"]; @@ -24,38 +24,38 @@ export type FullTabProps = BaseTabProps & { }; export interface MetricPoint { + bounce_rate?: number; date: string; pageviews?: number; - visitors?: number; sessions?: number; - bounce_rate?: number; + visitors?: number; [key: string]: string | number | undefined; } export interface TrackingOptions { + batchSize: number; + batchTimeout: number; disabled: boolean; - trackScreenViews: boolean; - trackHashChanges: boolean; - trackSessions: boolean; + enableBatching: boolean; + enableRetries: boolean; + initialRetryDelay: number; + maxRetries: number; + samplingRate: number; trackAttributes: boolean; - trackOutgoingLinks: boolean; + trackErrors: boolean; + trackHashChanges: boolean; trackInteractions: boolean; + trackOutgoingLinks: boolean; trackPerformance: boolean; + trackScreenViews: boolean; + trackSessions: boolean; trackWebVitals: boolean; - trackErrors: boolean; - samplingRate: number; - enableRetries: boolean; - maxRetries: number; - initialRetryDelay: number; - enableBatching: boolean; - batchSize: number; - batchTimeout: number; } export interface TrackingOptionConfig { - key: keyof TrackingOptions; - title: string; - description: string; data: string[]; + description: string; inverted?: boolean; + key: keyof TrackingOptions; + title: string; } diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx index 4aa5a670f..393c0890e 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/ui-components.tsx @@ -19,10 +19,10 @@ export const BORDER_RADIUS = { }; interface MetricToggleProps { - label: string; checked: boolean; - onChange: () => void; color: string; + label: string; + onChange: () => void; } export const MetricToggle: React.FC = ({ @@ -98,10 +98,10 @@ export const MetricToggle: React.FC = ({ }; interface MetricTogglesProps { - metrics: Record; - onToggle: (metric: string) => void; colors: Record; labels?: Record; + metrics: Record; + onToggle: (metric: string) => void; } const EMPTY_LABELS: Record = {}; @@ -126,11 +126,11 @@ export const MetricToggles: React.FC = ({ ); interface ExternalLinkButtonProps { + className?: string; href: string; label: string; - title?: string; - className?: string; showTooltip?: boolean; + title?: string; } export const ExternalLinkButton: React.FC = ({ diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/utils/use-chart-config.ts b/apps/dashboard/app/(main)/websites/[id]/_components/utils/use-chart-config.ts index 199b1ee60..9aff06232 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/utils/use-chart-config.ts +++ b/apps/dashboard/app/(main)/websites/[id]/_components/utils/use-chart-config.ts @@ -14,15 +14,15 @@ export type ChartMetric = export type MetricColors = Record; export interface ChartDataPoint { - [key: string]: any; date: string; + [key: string]: any; } interface UseChartConfigOptions { + colors?: Partial; data?: ChartDataPoint[]; - initialVisibleMetrics?: ChartMetric[]; granularity?: "daily" | "hourly"; - colors?: Partial; + initialVisibleMetrics?: ChartMetric[]; } export function useChartConfig({ diff --git a/apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx b/apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx index 7de61e056..17c49f03e 100644 --- a/apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx @@ -6,13 +6,11 @@ import { isWithinLimit, } from "@databuddy/shared/types/features"; import type { IconProps } from "@phosphor-icons/react"; -import { - ArrowClockwiseIcon, - ArrowLeftIcon, - BookIcon, - PlusIcon, - WarningIcon, -} from "@phosphor-icons/react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { ArrowLeftIcon } from "@phosphor-icons/react"; +import { BookIcon } from "@phosphor-icons/react"; +import { PlusIcon } from "@phosphor-icons/react"; +import { WarningIcon } from "@phosphor-icons/react"; import Link from "next/link"; import { cloneElement, type ReactNode } from "react"; import { useBillingContext } from "@/components/providers/billing-provider"; @@ -67,35 +65,34 @@ function WebsitePageHeaderSubtitle({ } interface WebsitePageHeaderProps { - title: string; + additionalActions?: ReactNode; + createActionLabel?: string; + currentUsage?: number; description?: string; - icon: React.ReactElement; - websiteId: string; - websiteName?: string; + docsUrl?: string; + errorMessage?: string; - isLoading?: boolean; - isRefreshing?: boolean; + // NEW: Feature usage tracking + feature?: GatedFeatureId; hasError?: boolean; - errorMessage?: string; + icon: React.ReactElement; - onRefreshAction?: () => void; + isLoading?: boolean; + isRefreshing?: boolean; onCreateAction?: () => void; - createActionLabel?: string; - subtitle?: string | ReactNode; + onRefreshAction?: () => void; showBackButton?: boolean; - variant?: "default" | "minimal"; - - additionalActions?: ReactNode; - docsUrl?: string; + subtitle?: string | ReactNode; + title: string; + variant?: "default" | "minimal"; - // NEW: Feature usage tracking - feature?: GatedFeatureId; - currentUsage?: number; + websiteId: string; + websiteName?: string; } export function WebsitePageHeader({ diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx index 809ee2051..81b5de6b7 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/agent/[chatId]/page.tsx @@ -1,4 +1,3 @@ -import { Suspense } from "react"; import { ChatProvider } from "@/contexts/chat-context"; import { AgentPageClient } from "../_components/agent-page-client"; @@ -11,19 +10,7 @@ export default async function AgentPage(props: Props) { return ( - }> - - + ); } - -function AgentPageSkeleton() { - return ( -
-
- Loading agent... -
-
- ); -} diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-atoms.ts b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-atoms.ts index 1a0a09043..c9acb5def 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-atoms.ts +++ b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-atoms.ts @@ -1,50 +1,18 @@ -import type { UIMessage } from "ai"; import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; -export type AgentStatus = - | "idle" - | "routing" - | "thinking" - | "analyzing" - | "searching" - | "generating" - | "visualizing" - | "complete" - | "error"; - -export interface AgentCommand { - id: string; - command: string; - title: string; - description: string; - toolName: string; - toolParams?: Record; - keywords: string[]; -} - -export const agentMessagesAtom = atom([]); -export const agentStatusAtom = atom< - "idle" | "streaming" | "submitted" | "error" ->("idle"); - -export const agentTitleAtom = atom(null); export const agentInputAtom = atom(""); -export const agentSuggestionsAtom = atom([]); -export const showCommandsAtom = atom(false); -export const commandQueryAtom = atom(""); -export const selectedCommandIndexAtom = atom(0); +export type AgentThinking = "off" | "low" | "medium" | "high"; -export const resetAgentUIAtom = atom(null, (_get, set) => { - set(agentTitleAtom, null); - set(agentInputAtom, ""); - set(agentSuggestionsAtom, []); - set(showCommandsAtom, false); - set(commandQueryAtom, ""); - set(selectedCommandIndexAtom, 0); -}); +export const AGENT_THINKING_LEVELS: readonly AgentThinking[] = [ + "off", + "low", + "medium", + "high", +] as const; -export const resetAgentMessagesAtom = atom(null, (_get, set) => { - set(agentMessagesAtom, []); - set(agentStatusAtom, "idle"); -}); +export const agentThinkingAtom = atomWithStorage( + "databuddy-agent-thinking", + "off" +); diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-chat-context.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-chat-context.tsx deleted file mode 100644 index 9c9d54acf..000000000 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-chat-context.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { useParams, useRouter } from "next/navigation"; -import { createContext, type PropsWithChildren, useContext } from "react"; - -interface AgentChatContextValue { - chatId: string; - setChatId: (id: string) => void; -} - -const AgentChatContext = createContext(null); - -interface AgentChatProviderProps extends PropsWithChildren { - chatId: string; -} - -export function AgentChatProvider({ - chatId, - children, -}: AgentChatProviderProps) { - const params = useParams(); - const router = useRouter(); - const websiteId = params.id as string; - - const setChatId = (id: string) => { - router.push(`/websites/${websiteId}/agent/${id}`); - }; - - return ( - - {children} - - ); -} - -export function useAgentChatId(): string { - const context = useContext(AgentChatContext); - if (!context) { - throw new Error("useAgentChatId must be used within AgentChatProvider"); - } - return context.chatId; -} - -export function useSetAgentChatId(): (id: string) => void { - const context = useContext(AgentChatContext); - if (!context) { - throw new Error("useSetAgentChatId must be used within AgentChatProvider"); - } - return context.setChatId; -} diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-command-menu.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-command-menu.tsx deleted file mode 100644 index 3c52d893f..000000000 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-command-menu.tsx +++ /dev/null @@ -1,111 +0,0 @@ -"use client"; - -import { ChartBarIcon } from "@phosphor-icons/react/dist/ssr/ChartBar"; -import { FileTextIcon } from "@phosphor-icons/react/dist/ssr/FileText"; -import { LightbulbIcon } from "@phosphor-icons/react/dist/ssr/Lightbulb"; -import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/ssr/MagnifyingGlass"; -import { TableIcon } from "@phosphor-icons/react/dist/ssr/Table"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import type { useAgentCommands } from "./hooks/use-agent-commands"; - -const COMMAND_ICONS: Record = { - analyze: MagnifyingGlassIcon, - report: FileTextIcon, - chart: ChartBarIcon, - show: TableIcon, - find: LightbulbIcon, - compare: ChartBarIcon, -}; - -function getCommandIcon(command: string) { - const prefix = command.replace("/", ""); - return COMMAND_ICONS[prefix] ?? MagnifyingGlassIcon; -} - -export function AgentCommandMenu({ - showCommands, - filteredCommands, - closeCommands, - executeCommand, -}: ReturnType) { - if (!showCommands || filteredCommands.length === 0) { - return null; - } - - return ( - { - if (!open) { - closeCommands(); - } - }} - open={showCommands} - > - - - - - - - - No commands found. - - {filteredCommands.map((command) => { - const Icon = getCommandIcon(command.command); - - return ( - { - executeCommand(command); - }} - value={command.title} - > -
- -
-
-

- {command.title} -

-

- {command.description} -

-
- - {command.command} - -
- ); - })} -
-
-
-
-
- ); -} diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-commands.ts b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-commands.ts deleted file mode 100644 index 12135d95e..000000000 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-commands.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { AgentCommand } from "./agent-atoms"; - -export const AGENT_COMMANDS: AgentCommand[] = [ - // Traffic Analysis - { - id: "analyze-traffic", - command: "/analyze", - title: "Analyze traffic patterns", - description: "Deep dive into your traffic data and trends", - toolName: "analyze_traffic", - keywords: ["analyze", "traffic", "patterns", "trends", "visitors"], - }, - { - id: "analyze-sources", - command: "/analyze", - title: "Analyze traffic sources", - description: "Break down traffic by source and medium", - toolName: "analyze_sources", - keywords: ["analyze", "sources", "referrers", "channels", "medium"], - }, - { - id: "analyze-conversions", - command: "/analyze", - title: "Analyze conversion funnel", - description: "Identify drop-offs in your conversion funnel", - toolName: "analyze_funnel", - keywords: ["analyze", "conversions", "funnel", "drop-off", "goals"], - }, - // Reports - { - id: "report-weekly", - command: "/report", - title: "Generate weekly report", - description: "Create a comprehensive weekly analytics report", - toolName: "generate_report", - toolParams: { period: "week" }, - keywords: ["report", "weekly", "summary", "overview"], - }, - { - id: "report-monthly", - command: "/report", - title: "Generate monthly report", - description: "Create a detailed monthly performance report", - toolName: "generate_report", - toolParams: { period: "month" }, - keywords: ["report", "monthly", "summary", "performance"], - }, - // Visualizations - { - id: "chart-traffic", - command: "/chart", - title: "Create traffic chart", - description: "Visualize traffic trends over time", - toolName: "create_chart", - toolParams: { type: "traffic" }, - keywords: ["chart", "traffic", "visualization", "graph", "trend"], - }, - { - id: "chart-sources", - command: "/chart", - title: "Create sources breakdown", - description: "Pie chart of traffic sources", - toolName: "create_chart", - toolParams: { type: "sources" }, - keywords: ["chart", "sources", "pie", "breakdown"], - }, - // Show data - { - id: "show-top-pages", - command: "/show", - title: "Show top pages", - description: "Display your most visited pages", - toolName: "get_top_pages", - keywords: ["show", "top", "pages", "popular", "views"], - }, - { - id: "show-events", - command: "/show", - title: "Show recent events", - description: "Display recent tracked events", - toolName: "get_events", - keywords: ["show", "events", "recent", "actions", "tracking"], - }, - { - id: "show-sessions", - command: "/show", - title: "Show active sessions", - description: "Display currently active user sessions", - toolName: "get_sessions", - keywords: ["show", "sessions", "active", "users", "live"], - }, - // Find/Search - { - id: "find-anomalies", - command: "/find", - title: "Find traffic anomalies", - description: "Detect unusual patterns in your data", - toolName: "find_anomalies", - keywords: ["find", "anomalies", "unusual", "spikes", "drops"], - }, - { - id: "find-insights", - command: "/find", - title: "Find actionable insights", - description: "Discover opportunities to improve", - toolName: "find_insights", - keywords: [ - "find", - "insights", - "opportunities", - "improve", - "recommendations", - ], - }, - // Compare - { - id: "compare-periods", - command: "/compare", - title: "Compare time periods", - description: "Compare metrics between two time periods", - toolName: "compare_periods", - keywords: ["compare", "periods", "before", "after", "change"], - }, -]; - -export function filterCommands(query: string): AgentCommand[] { - if (!query) { - return AGENT_COMMANDS; - } - - const normalizedQuery = query.toLowerCase().trim(); - - return AGENT_COMMANDS.filter((cmd) => { - const matchesCommand = cmd.command.toLowerCase().includes(normalizedQuery); - const matchesTitle = cmd.title.toLowerCase().includes(normalizedQuery); - const matchesDescription = cmd.description - .toLowerCase() - .includes(normalizedQuery); - const matchesKeywords = cmd.keywords.some((kw) => - kw.includes(normalizedQuery) - ); - - return ( - matchesCommand || matchesTitle || matchesDescription || matchesKeywords - ); - }); -} diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-error-message.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-error-message.tsx index 42ca67868..7fb5d3e11 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-error-message.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-error-message.tsx @@ -1,6 +1,7 @@ "use client"; -import { ArrowClockwiseIcon, XCircleIcon } from "@phosphor-icons/react"; +import { ArrowClockwiseIcon } from "@phosphor-icons/react"; +import { XCircleIcon } from "@phosphor-icons/react"; import { useCallback, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-header.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-header.tsx deleted file mode 100644 index 190bc1494..000000000 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-header.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { AgentNavigation } from "./agent-navigation"; -import { AgentTitle } from "./agent-title"; -import { NewChatButton } from "./new-chat-button"; - -interface AgentHeaderProps { - showBackButton?: boolean; -} - -export function AgentHeader({ showBackButton = false }: AgentHeaderProps) { - return ( -
- {showBackButton && } - -
- -
-
- ); -} diff --git a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx index 16d6e05c3..399f6a73f 100644 --- a/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-input.tsx @@ -1,179 +1,221 @@ "use client"; -import { ClockCountdownIcon } from "@phosphor-icons/react/dist/ssr/ClockCountdown"; -import { PaperPlaneRightIcon } from "@phosphor-icons/react/dist/ssr/PaperPlaneRight"; -import { StopIcon } from "@phosphor-icons/react/dist/ssr/Stop"; -import { XIcon } from "@phosphor-icons/react/dist/ssr/X"; -import type { UIMessage } from "ai"; -import { useAtom } from "jotai"; -import { useParams } from "next/navigation"; import { - Queue, - QueueItem, - QueueItemAction, - QueueItemActions, - QueueItemContent, - QueueItemIndicator, - QueueList, - QueueSection, - QueueSectionContent, - QueueSectionLabel, - QueueSectionTrigger, -} from "@/components/ai-elements/queue"; + BrainIcon, + CaretDownIcon, + ClockCountdownIcon, + PaperPlaneRightIcon, + StopIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useAtom } from "jotai"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Textarea } from "@/components/ui/textarea"; import { useChat, usePendingQueue } from "@/contexts/chat-context"; import { cn } from "@/lib/utils"; -import { agentInputAtom } from "./agent-atoms"; -import { useAgentChatId, useSetAgentChatId } from "./agent-chat-context"; -import { AgentCommandMenu } from "./agent-command-menu"; -import { useAgentCommands } from "./hooks/use-agent-commands"; -import { useChatList } from "./hooks/use-chat-db"; +import { + AGENT_THINKING_LEVELS, + type AgentThinking, + agentInputAtom, + agentThinkingAtom, +} from "./agent-atoms"; import { useEnterSubmit } from "./hooks/use-enter-submit"; -function getChatTitle(messages: UIMessage[], currentInput: string): string { - const firstUserMsg = messages.find((m) => m.role === "user"); - if (firstUserMsg) { - const text = firstUserMsg.parts - .filter( - (p): p is Extract => - p.type === "text" - ) - .map((p) => p.text) - .join(" ") - .trim(); - return text.slice(0, 100) || "New conversation"; - } - return currentInput.slice(0, 100) || "New conversation"; -} - export function AgentInput() { - const { sendMessage, stop, status, messages } = useChat(); + const { sendMessage, stop, status } = useChat(); const { messages: pendingMessages, removeAction } = usePendingQueue(); const isLoading = status === "streaming" || status === "submitted"; const [input, setInput] = useAtom(agentInputAtom); - const agentCommands = useAgentCommands(); - const currentChatId = useAgentChatId(); - const setChatId = useSetAgentChatId(); const { formRef, onKeyDown } = useEnterSubmit(); - const params = useParams(); - const websiteId = params.id as string; - const { saveChat } = useChatList(websiteId); const handleSubmit = (e?: React.FormEvent) => { e?.preventDefault(); if (!input.trim()) { return; } - if (currentChatId) { - setChatId(currentChatId); - } - - const text = input.trim(); - const title = getChatTitle(messages, text); - saveChat({ id: currentChatId, websiteId, title }); - - sendMessage({ text }); + sendMessage({ text: input.trim() }); setInput(""); }; - const handleChange = (e: React.ChangeEvent) => { - agentCommands.handleInputChange( - e.target.value, - e.target.selectionStart ?? 0 - ); - }; + return ( +
+ {pendingMessages.length > 0 ? ( + + ) : null} - const handleStop = (e: React.MouseEvent) => { - e.preventDefault(); - stop(); - }; +
+