Initial commit
This commit is contained in:
189
references/case-context.md
Normal file
189
references/case-context.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Case Context Management - CareBridge
|
||||
|
||||
## Overview
|
||||
|
||||
CareBridge is a multi-case management system where users can manage multiple care recipients. The `caseId` is the primary context identifier that must be preserved across ALL navigation.
|
||||
|
||||
## Critical Rule
|
||||
|
||||
**NEVER navigate to a page without preserving the current `caseId`.**
|
||||
|
||||
## How Case Context Works
|
||||
|
||||
### URL Structure
|
||||
|
||||
All protected routes must support the `caseId` query parameter:
|
||||
|
||||
```
|
||||
/dashboard?caseId=uuid-here
|
||||
/calendar?caseId=uuid-here
|
||||
/subscriptions?caseId=uuid-here
|
||||
/case-settings?caseId=uuid-here
|
||||
```
|
||||
|
||||
### Reading Case Context
|
||||
|
||||
Use the helper function to safely extract `caseId`:
|
||||
|
||||
```typescript
|
||||
// In Server Components (pages)
|
||||
import { getCaseIdFromParams } from '@/lib/case-context'
|
||||
|
||||
type SearchParams = Promise<{ caseId?: string }>
|
||||
|
||||
export default async function MyPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams
|
||||
}) {
|
||||
const params = await searchParams // Next.js 15 requirement
|
||||
const caseId = getCaseIdFromParams(params)
|
||||
|
||||
if (!caseId) {
|
||||
// Show "No case selected" empty state
|
||||
return <EmptyState />
|
||||
}
|
||||
|
||||
// Use caseId for data fetching
|
||||
const data = await getData(caseId)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// In Client Components
|
||||
'use client'
|
||||
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function MyClientComponent() {
|
||||
const searchParams = useSearchParams()
|
||||
const [caseId, setCaseId] = useState<string | null>(null)
|
||||
|
||||
// Prevent hydration mismatch
|
||||
useEffect(() => {
|
||||
setCaseId(searchParams.get('caseId'))
|
||||
}, [searchParams])
|
||||
|
||||
if (!caseId) return null
|
||||
|
||||
// Use caseId
|
||||
}
|
||||
```
|
||||
|
||||
### Preserving Case Context in Navigation
|
||||
|
||||
**All navigation links MUST preserve caseId:**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Preserves caseId
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const caseId = searchParams.get('caseId')
|
||||
|
||||
<Link href={caseId ? `/dashboard?caseId=${caseId}` : '/dashboard'}>
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
// ❌ WRONG - Loses case context
|
||||
<Link href="/dashboard">
|
||||
Dashboard
|
||||
</Link>
|
||||
```
|
||||
|
||||
### Navigation Component Pattern
|
||||
|
||||
See `src/components/nav-main.tsx` for the correct pattern:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
export function NavMain({ items }) {
|
||||
const searchParams = useSearchParams()
|
||||
const [caseId, setCaseId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setCaseId(searchParams.get('caseId'))
|
||||
}, [searchParams])
|
||||
|
||||
return items.map((item) => {
|
||||
// Preserve caseId in ALL navigation
|
||||
const url = caseId ? `${item.url}?caseId=${caseId}` : item.url
|
||||
|
||||
return <Link href={url}>{item.title}</Link>
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Case Switching
|
||||
|
||||
Case switching happens in the sidebar via `CaseSwitcher` component. When a user selects a different case:
|
||||
|
||||
1. The URL updates with the new `caseId`
|
||||
2. All navigation automatically preserves the new `caseId`
|
||||
3. The page re-renders with new case data
|
||||
|
||||
## Empty States
|
||||
|
||||
When `caseId` is missing, show a helpful empty state:
|
||||
|
||||
```typescript
|
||||
if (!caseId) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No Case Selected"
|
||||
description="Select a case from the sidebar to continue"
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Mistake #1: Forgetting to preserve caseId in links
|
||||
|
||||
```typescript
|
||||
// WRONG - Will break case context
|
||||
<Link href="/subscriptions">Subscribe</Link>
|
||||
```
|
||||
|
||||
### ❌ Mistake #2: Not handling Next.js 15 async params
|
||||
|
||||
```typescript
|
||||
// WRONG - In Next.js 15, params are async
|
||||
export default function Page({ searchParams }) {
|
||||
const caseId = searchParams.caseId // ERROR!
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
export default async function Page({ searchParams }) {
|
||||
const params = await searchParams
|
||||
const caseId = getCaseIdFromParams(params)
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #3: Using router.push without caseId
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
router.push('/dashboard')
|
||||
|
||||
// CORRECT
|
||||
const caseId = searchParams.get('caseId')
|
||||
router.push(caseId ? `/dashboard?caseId=${caseId}` : '/dashboard')
|
||||
```
|
||||
|
||||
## Testing Case Context
|
||||
|
||||
When testing a feature:
|
||||
|
||||
1. Navigate to a page with a caseId
|
||||
2. Click any link/button that navigates
|
||||
3. Verify the URL still has `?caseId=...`
|
||||
4. Repeat for all navigation paths
|
||||
|
||||
If caseId is lost, you'll see the "No Case Selected" empty state - this indicates broken context preservation.
|
||||
435
references/component-standards.md
Normal file
435
references/component-standards.md
Normal file
@@ -0,0 +1,435 @@
|
||||
# Component Standards - CareBridge
|
||||
|
||||
## Critical Rule: No Custom Components
|
||||
|
||||
**NEVER create custom UI components from scratch. ALWAYS use shadcn/ui.**
|
||||
|
||||
## Using shadcn/ui
|
||||
|
||||
### Adding Components
|
||||
|
||||
```bash
|
||||
# List available components
|
||||
npx shadcn@latest add
|
||||
|
||||
# Add a specific component
|
||||
npx shadcn@latest add button
|
||||
npx shadcn@latest add card
|
||||
npx shadcn@latest add dialog
|
||||
npx shadcn@latest add form
|
||||
```
|
||||
|
||||
This adds components to `src/components/ui/` with proper TypeScript types and styling.
|
||||
|
||||
### Example: Adding a Button
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
Then use it:
|
||||
|
||||
```typescript
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function MyComponent() {
|
||||
return <Button variant="default">Click me</Button>
|
||||
}
|
||||
```
|
||||
|
||||
### Available Variants
|
||||
|
||||
shadcn components come with built-in variants. Check the component file for options:
|
||||
|
||||
```typescript
|
||||
// Button variants
|
||||
<Button variant="default">Default</Button>
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="link">Link</Button>
|
||||
|
||||
// Card components
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Content here
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
Footer actions
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Component Organization
|
||||
|
||||
### Location
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn components (DON'T EDIT)
|
||||
│ │ ├── button.tsx
|
||||
│ │ ├── card.tsx
|
||||
│ │ └── ...
|
||||
│ ├── dashboard/ # Dashboard-specific components
|
||||
│ ├── calendar/ # Calendar-specific components
|
||||
│ ├── concierge/ # Concierge-specific components
|
||||
│ └── nav-main.tsx # Global navigation components
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- UI components: `kebab-case.tsx` (button.tsx, card.tsx)
|
||||
- Feature components: `PascalCase.tsx` (DashboardHeader.tsx, CaseList.tsx)
|
||||
- Use descriptive names: `ConciergeBookingForm.tsx` not `Form.tsx`
|
||||
|
||||
## Styling Rules
|
||||
|
||||
### CSS Variables (READ-ONLY)
|
||||
|
||||
**NEVER create custom CSS variables. ONLY use existing ones from `globals.css`.**
|
||||
|
||||
Available CSS variables:
|
||||
|
||||
```css
|
||||
/* From globals.css */
|
||||
--background
|
||||
--foreground
|
||||
--card
|
||||
--card-foreground
|
||||
--popover
|
||||
--popover-foreground
|
||||
--primary
|
||||
--primary-foreground
|
||||
--secondary
|
||||
--secondary-foreground
|
||||
--muted
|
||||
--muted-foreground
|
||||
--accent
|
||||
--accent-foreground
|
||||
--destructive
|
||||
--destructive-foreground
|
||||
--border
|
||||
--input
|
||||
--ring
|
||||
```
|
||||
|
||||
### Using CSS Variables
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use existing variables
|
||||
<div className="bg-background text-foreground">
|
||||
Content
|
||||
</div>
|
||||
|
||||
// ✅ CORRECT - Use Tailwind utility classes
|
||||
<div className="bg-primary text-primary-foreground rounded-lg p-4">
|
||||
Card content
|
||||
</div>
|
||||
|
||||
// ❌ WRONG - Custom CSS variables
|
||||
<div style={{ backgroundColor: 'var(--my-custom-color)' }}>
|
||||
Content
|
||||
</div>
|
||||
|
||||
// ❌ WRONG - Inline styles
|
||||
<div style={{ backgroundColor: '#3498db', padding: '16px' }}>
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
### Tailwind Classes
|
||||
|
||||
Use Tailwind utility classes for styling:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
<div className="flex items-center gap-4 p-6 rounded-lg border">
|
||||
<div className="flex-1">Content</div>
|
||||
<Button variant="default">Action</Button>
|
||||
</div>
|
||||
|
||||
// ❌ WRONG - Custom CSS classes
|
||||
<div className="my-custom-container">
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
## Form Components
|
||||
|
||||
### Using React Hook Form + Zod
|
||||
|
||||
CareBridge uses React Hook Form with Zod validation:
|
||||
|
||||
```typescript
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
email: z.string().email('Invalid email'),
|
||||
})
|
||||
|
||||
export function MyForm() {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
// Handle submission
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Dialog/Modal Pattern
|
||||
|
||||
```typescript
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function MyDialog() {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Open Dialog</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dialog Title</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div>Dialog content here</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Empty States Pattern
|
||||
|
||||
Use the `EmptyState` component for "no data" scenarios:
|
||||
|
||||
```typescript
|
||||
import { EmptyState } from '@/components/dashboard/empty-state'
|
||||
import { Users } from 'lucide-react'
|
||||
|
||||
export function MyComponent({ data }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="No Cases Found"
|
||||
description="Create your first care case to get started"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <div>{/* Render data */}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Loading States
|
||||
|
||||
```typescript
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function LoadingState() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Icons
|
||||
|
||||
Use Lucide React for icons:
|
||||
|
||||
```typescript
|
||||
import { Calendar, User, Settings, CreditCard } from 'lucide-react'
|
||||
|
||||
export function MyComponent() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Calendar</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Common Component Patterns
|
||||
|
||||
### Card with Actions
|
||||
|
||||
```typescript
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Title</CardTitle>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>Description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Content here
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-2">
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Save</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### List with Items
|
||||
|
||||
```typescript
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div>
|
||||
<h3 className="font-medium">{item.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
Use Tailwind responsive prefixes:
|
||||
|
||||
```typescript
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Responsive grid */}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Stack vertically on mobile, horizontally on larger screens */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Mistake #1: Creating custom components
|
||||
|
||||
```typescript
|
||||
// WRONG - Don't create custom buttons
|
||||
export function CustomButton({ children }) {
|
||||
return (
|
||||
<button className="custom-btn">
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// CORRECT - Use shadcn button
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
<Button variant="default">{children}</Button>
|
||||
```
|
||||
|
||||
### ❌ Mistake #2: Custom CSS variables
|
||||
|
||||
```typescript
|
||||
// WRONG - Don't create custom variables
|
||||
<div style={{ color: 'var(--my-custom-color)' }}>
|
||||
Content
|
||||
</div>
|
||||
|
||||
// CORRECT - Use existing variables
|
||||
<div className="text-primary">
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
### ❌ Mistake #3: Inline styles
|
||||
|
||||
```typescript
|
||||
// WRONG - Avoid inline styles
|
||||
<div style={{ padding: '16px', margin: '8px' }}>
|
||||
Content
|
||||
</div>
|
||||
|
||||
// CORRECT - Use Tailwind classes
|
||||
<div className="p-4 m-2">
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
### ❌ Mistake #4: Not using TypeScript types
|
||||
|
||||
```typescript
|
||||
// WRONG - No types
|
||||
export function MyComponent({ user }) {
|
||||
return <div>{user.name}</div>
|
||||
}
|
||||
|
||||
// CORRECT - Proper TypeScript types
|
||||
type Props = {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
export function MyComponent({ user }: Props) {
|
||||
return <div>{user.name}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Component Checklist
|
||||
|
||||
Before creating a component:
|
||||
|
||||
- [ ] Is there a shadcn component for this? (Use `npx shadcn@latest add`)
|
||||
- [ ] Am I using existing CSS variables?
|
||||
- [ ] Am I using Tailwind classes instead of inline styles?
|
||||
- [ ] Do I have proper TypeScript types?
|
||||
- [ ] Is the component in the right directory?
|
||||
- [ ] Am I preserving case context in navigation?
|
||||
588
references/database-patterns.md
Normal file
588
references/database-patterns.md
Normal file
@@ -0,0 +1,588 @@
|
||||
# Database Patterns - CareBridge
|
||||
|
||||
## Overview
|
||||
|
||||
CareBridge uses Supabase (PostgreSQL) for data storage. Security is enforced via **Clerk authentication + Server Actions** for most tables, with RLS enabled only for specific tables (user_profiles, consultations, subscriptions).
|
||||
|
||||
## Database Schema Reference
|
||||
|
||||
### Auto-Generated Types (Single Source of Truth)
|
||||
|
||||
The complete database schema is auto-generated in **`src/lib/database.types.ts`**.
|
||||
|
||||
**To regenerate types after migrations:**
|
||||
|
||||
```bash
|
||||
# From linked remote project (recommended)
|
||||
npx supabase gen types typescript --linked > src/lib/database.types.ts
|
||||
|
||||
# From local dev database
|
||||
npx supabase gen types typescript --local > src/lib/database.types.ts
|
||||
|
||||
# From specific project ID
|
||||
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > src/lib/database.types.ts
|
||||
```
|
||||
|
||||
**ALWAYS regenerate types after creating migrations!**
|
||||
|
||||
## Supabase Client Patterns
|
||||
|
||||
### Server-Side Client (Preferred)
|
||||
|
||||
Use in Server Components and Server Actions:
|
||||
|
||||
```typescript
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export default async function Page() {
|
||||
const supabase = await createServerClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('owner_id', userId)
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
return <ErrorDisplay />
|
||||
}
|
||||
|
||||
return <CasesList cases={data} />
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Client (When Needed)
|
||||
|
||||
Use in Client Components:
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { createBrowserClient } from '@/lib/supabase/client'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function ClientComponent() {
|
||||
const [data, setData] = useState(null)
|
||||
const supabase = createBrowserClient()
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const { data } = await supabase.from('cases').select('*')
|
||||
setData(data)
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
### Creating Migrations
|
||||
|
||||
**ALWAYS use Supabase CLI for schema changes:**
|
||||
|
||||
```bash
|
||||
# Create a new migration
|
||||
npx supabase migration new migration_name
|
||||
|
||||
# This creates: supabase/migrations/YYYYMMDD_migration_name.sql
|
||||
```
|
||||
|
||||
### Migration Structure
|
||||
|
||||
```sql
|
||||
-- Migration: 20251018_add_concierge_packages.sql
|
||||
|
||||
-- Create table
|
||||
CREATE TABLE IF NOT EXISTS concierge_packages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
clerk_user_id TEXT NOT NULL,
|
||||
package_type VARCHAR(50) NOT NULL,
|
||||
price_paid_cents INT NOT NULL CHECK (price_paid_cents > 0),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX idx_concierge_packages_user
|
||||
ON concierge_packages(clerk_user_id);
|
||||
|
||||
CREATE INDEX idx_concierge_packages_status
|
||||
ON concierge_packages(status);
|
||||
|
||||
-- Add table comment
|
||||
COMMENT ON TABLE concierge_packages IS 'Stores concierge service package purchases. Security enforced by Clerk auth + Server Actions (RLS disabled)';
|
||||
|
||||
-- Create trigger for updated_at (if function exists)
|
||||
CREATE TRIGGER update_concierge_packages_updated_at
|
||||
BEFORE UPDATE ON concierge_packages
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Add column comments
|
||||
COMMENT ON COLUMN concierge_packages.price_paid_cents IS 'Price paid in cents (e.g., 39900 = $399.00)';
|
||||
```
|
||||
|
||||
### Applying Migrations
|
||||
|
||||
```bash
|
||||
# Apply locally
|
||||
npx supabase db reset
|
||||
|
||||
# Apply to remote (production)
|
||||
npx supabase db push
|
||||
```
|
||||
|
||||
## Security Architecture
|
||||
|
||||
### CareBridge Uses Two Security Patterns:
|
||||
|
||||
**Pattern 1: Clerk Authentication + Server Actions (Most Tables)**
|
||||
|
||||
Most tables have RLS **disabled** and rely on:
|
||||
- Clerk authentication for user identity
|
||||
- Server Actions that enforce authorization logic
|
||||
- Server-side validation before database operations
|
||||
|
||||
```sql
|
||||
-- Example: Most tables use this pattern
|
||||
CREATE TABLE cases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id TEXT NOT NULL,
|
||||
-- ... other columns
|
||||
);
|
||||
|
||||
-- RLS is DISABLED - security enforced in Server Actions
|
||||
COMMENT ON TABLE cases IS 'Security enforced by Clerk auth + Server Actions (RLS disabled)';
|
||||
```
|
||||
|
||||
**Pattern 2: Row-Level Security (Specific Tables Only)**
|
||||
|
||||
Only 3 tables use RLS:
|
||||
- `user_profiles` - User profile data
|
||||
- `consultations` - Consultation bookings
|
||||
- `subscriptions` - Subscription data
|
||||
|
||||
```sql
|
||||
-- Example: Tables with RLS enabled
|
||||
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "users_own_profile"
|
||||
ON user_profiles
|
||||
FOR ALL
|
||||
USING (auth.jwt() ->> 'sub' = clerk_user_id);
|
||||
```
|
||||
|
||||
### When to Use Each Pattern
|
||||
|
||||
**Use Clerk + Server Actions (default):**
|
||||
- Complex authorization logic (team access, roles)
|
||||
- Multi-table operations requiring consistency
|
||||
- Business logic validation
|
||||
- Most application features
|
||||
|
||||
**Use RLS (rare):**
|
||||
- Simple user-owned resources
|
||||
- Direct client queries (rare in Next.js)
|
||||
- Additional security layer for sensitive data
|
||||
|
||||
### Server Action Security Pattern
|
||||
|
||||
```typescript
|
||||
// src/lib/actions/case-actions.ts
|
||||
'use server'
|
||||
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export async function getCases() {
|
||||
const { userId } = await auth()
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
const supabase = await createServerClient()
|
||||
|
||||
// Security: Only fetch cases owned by authenticated user
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('owner_id', userId)
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
```
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Basic CRUD
|
||||
|
||||
```typescript
|
||||
// Create
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.insert({
|
||||
owner_id: userId,
|
||||
care_recipient_name: 'John Doe',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Read (single)
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('id', caseId)
|
||||
.single()
|
||||
|
||||
// Read (multiple)
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('owner_id', userId)
|
||||
|
||||
// Update
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.update({ care_recipient_name: 'Jane Doe' })
|
||||
.eq('id', caseId)
|
||||
|
||||
// Delete
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.delete()
|
||||
.eq('id', caseId)
|
||||
```
|
||||
|
||||
### Joins (Relations)
|
||||
|
||||
```typescript
|
||||
// Get cases with their members
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select(`
|
||||
*,
|
||||
case_members (
|
||||
id,
|
||||
user_id,
|
||||
role
|
||||
)
|
||||
`)
|
||||
.eq('owner_id', userId)
|
||||
```
|
||||
|
||||
### Filtering
|
||||
|
||||
```typescript
|
||||
// Equals
|
||||
.eq('status', 'active')
|
||||
|
||||
// Not equals
|
||||
.neq('status', 'deleted')
|
||||
|
||||
// Greater than
|
||||
.gt('created_at', '2024-01-01')
|
||||
|
||||
// In array
|
||||
.in('status', ['active', 'pending'])
|
||||
|
||||
// Is null
|
||||
.is('deleted_at', null)
|
||||
|
||||
// Like (case-insensitive)
|
||||
.ilike('name', '%john%')
|
||||
|
||||
// Order
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// Limit
|
||||
.limit(10)
|
||||
|
||||
// Range (pagination)
|
||||
.range(0, 9) // First 10 items
|
||||
```
|
||||
|
||||
### Counting
|
||||
|
||||
```typescript
|
||||
const { count, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('owner_id', userId)
|
||||
```
|
||||
|
||||
### Upsert (Insert or Update)
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.upsert({
|
||||
clerk_user_id: userId,
|
||||
is_active: true,
|
||||
})
|
||||
.select()
|
||||
```
|
||||
|
||||
## Transaction Patterns
|
||||
|
||||
### Using RPC for Transactions
|
||||
|
||||
Create a database function:
|
||||
|
||||
```sql
|
||||
-- supabase/migrations/20251018_create_case_with_member.sql
|
||||
CREATE OR REPLACE FUNCTION create_case_with_member(
|
||||
p_owner_id TEXT,
|
||||
p_case_name TEXT
|
||||
) RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_case_id UUID;
|
||||
BEGIN
|
||||
-- Insert case
|
||||
INSERT INTO cases (owner_id, care_recipient_name)
|
||||
VALUES (p_owner_id, p_case_name)
|
||||
RETURNING id INTO v_case_id;
|
||||
|
||||
-- Insert owner as member
|
||||
INSERT INTO case_members (case_id, user_id, role)
|
||||
VALUES (v_case_id, p_owner_id, 'owner');
|
||||
|
||||
RETURN v_case_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
Call from TypeScript:
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.rpc('create_case_with_member', {
|
||||
p_owner_id: userId,
|
||||
p_case_name: 'John Doe',
|
||||
})
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase
|
||||
.from('cases')
|
||||
.select('*')
|
||||
.eq('id', caseId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
|
||||
// Check for specific error codes
|
||||
if (error.code === 'PGRST116') {
|
||||
// Not found
|
||||
return { error: 'Case not found' }
|
||||
}
|
||||
|
||||
return { error: 'Failed to fetch case' }
|
||||
}
|
||||
|
||||
return { data }
|
||||
```
|
||||
|
||||
## Type Safety with Auto-Generated Types
|
||||
|
||||
CareBridge uses auto-generated types from `src/lib/database.types.ts`:
|
||||
|
||||
```typescript
|
||||
import { Database } from '@/lib/database.types'
|
||||
|
||||
// Extract table types
|
||||
type Case = Database['public']['Tables']['cases']['Row']
|
||||
type CaseInsert = Database['public']['Tables']['cases']['Insert']
|
||||
type CaseUpdate = Database['public']['Tables']['cases']['Update']
|
||||
|
||||
// Use with Supabase client
|
||||
const supabase = createServerClient<Database>()
|
||||
|
||||
// Typed queries
|
||||
const { data, error } = await supabase
|
||||
.from('cases') // ✅ Autocomplete for table names
|
||||
.select('*') // ✅ Typed return value
|
||||
.eq('id', caseId)
|
||||
```
|
||||
|
||||
**Remember to regenerate types after migrations:**
|
||||
|
||||
```bash
|
||||
npx supabase gen types typescript --linked > src/lib/database.types.ts
|
||||
```
|
||||
|
||||
## Realtime Subscriptions
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createBrowserClient } from '@/lib/supabase/client'
|
||||
|
||||
export function RealtimeComponent() {
|
||||
const supabase = createBrowserClient()
|
||||
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel('cases-changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'cases',
|
||||
},
|
||||
(payload) => {
|
||||
console.log('Change received!', payload)
|
||||
// Update UI
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <div>Listening for changes...</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Mistake #1: Not checking for errors
|
||||
|
||||
```typescript
|
||||
// WRONG - No error handling
|
||||
const { data } = await supabase.from('cases').select('*')
|
||||
return data // Could be null!
|
||||
|
||||
// CORRECT
|
||||
const { data, error } = await supabase.from('cases').select('*')
|
||||
|
||||
if (error) {
|
||||
console.error('Error:', error)
|
||||
return { error: error.message }
|
||||
}
|
||||
|
||||
return { data }
|
||||
```
|
||||
|
||||
### ❌ Mistake #2: Using client in server components
|
||||
|
||||
```typescript
|
||||
// WRONG - Don't import client in server components
|
||||
import { createBrowserClient } from '@/lib/supabase/client'
|
||||
|
||||
export default async function Page() {
|
||||
const supabase = createBrowserClient() // ERROR!
|
||||
}
|
||||
|
||||
// CORRECT - Use server client
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export default async function Page() {
|
||||
const supabase = await createServerClient()
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #3: Not securing Server Actions
|
||||
|
||||
```typescript
|
||||
// WRONG - No authentication check
|
||||
'use server'
|
||||
|
||||
export async function deleteCase(caseId: string) {
|
||||
const supabase = await createServerClient()
|
||||
await supabase.from('cases').delete().eq('id', caseId)
|
||||
// Anyone can delete any case!
|
||||
}
|
||||
|
||||
// CORRECT - Verify user owns the case
|
||||
'use server'
|
||||
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
|
||||
export async function deleteCase(caseId: string) {
|
||||
const { userId } = await auth()
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
const supabase = await createServerClient()
|
||||
|
||||
// Verify ownership before deleting
|
||||
const { data: caseData } = await supabase
|
||||
.from('cases')
|
||||
.select('owner_id')
|
||||
.eq('id', caseId)
|
||||
.single()
|
||||
|
||||
if (caseData?.owner_id !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
await supabase.from('cases').delete().eq('id', caseId)
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #4: Not using indexes
|
||||
|
||||
```sql
|
||||
-- WRONG - Frequently queried column without index
|
||||
CREATE TABLE cases (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL -- No index!
|
||||
);
|
||||
|
||||
-- CORRECT - Add indexes for frequently queried columns
|
||||
CREATE TABLE cases (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cases_owner ON cases(owner_id);
|
||||
```
|
||||
|
||||
### ❌ Mistake #5: Not using migrations
|
||||
|
||||
```typescript
|
||||
// WRONG - Creating tables via SQL in application code
|
||||
await supabase.sql`CREATE TABLE ...` // Don't do this!
|
||||
|
||||
// CORRECT - Use migrations
|
||||
// Create: supabase/migrations/20251018_create_table.sql
|
||||
// Then run: npx supabase db push
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use indexes** for frequently queried columns
|
||||
2. **Select specific columns** instead of `select('*')`
|
||||
3. **Use pagination** with `.range()` for large datasets
|
||||
4. **Batch operations** when possible
|
||||
5. **Use RPC functions** for complex operations
|
||||
6. **Cache results** when appropriate
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When creating a migration:
|
||||
|
||||
- [ ] Use `npx supabase migration new name`
|
||||
- [ ] Include `IF NOT EXISTS` clauses
|
||||
- [ ] Add appropriate indexes for frequently queried columns
|
||||
- [ ] Add table comment indicating security pattern (RLS vs Clerk + Server Actions)
|
||||
- [ ] Only enable RLS if needed (most tables use Clerk + Server Actions)
|
||||
- [ ] Add `updated_at` trigger if needed
|
||||
- [ ] Add column comments for documentation
|
||||
- [ ] Test locally with `npx supabase db reset`
|
||||
- [ ] Push to production with `npx supabase db push`
|
||||
- [ ] **Regenerate TypeScript types:** `npx supabase gen types typescript --linked > src/lib/database.types.ts`
|
||||
446
references/nextjs-15-patterns.md
Normal file
446
references/nextjs-15-patterns.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# Next.js 15 Patterns - CareBridge
|
||||
|
||||
## Critical Changes from Next.js 14
|
||||
|
||||
Next.js 15 introduces async Request APIs. Several patterns have changed.
|
||||
|
||||
## Async Params (CRITICAL)
|
||||
|
||||
### In App Router Pages
|
||||
|
||||
**ALL dynamic route params must be awaited**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Next.js 14 pattern (will error in 15)
|
||||
export default function Page({ params }: { params: { id: string } }) {
|
||||
const { id } = params // ERROR!
|
||||
}
|
||||
|
||||
// ✅ CORRECT - Next.js 15 pattern
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const { id } = await params
|
||||
// Use id
|
||||
}
|
||||
```
|
||||
|
||||
### In API Routes
|
||||
|
||||
**ALL dynamic route params must be awaited**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { userId: string } }
|
||||
) {
|
||||
const { userId } = params // ERROR!
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ userId: string }> }
|
||||
) {
|
||||
const { userId } = await params
|
||||
// Use userId
|
||||
}
|
||||
```
|
||||
|
||||
## Async SearchParams
|
||||
|
||||
**SearchParams are also async in Next.js 15**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG
|
||||
export default function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { caseId?: string }
|
||||
}) {
|
||||
const caseId = searchParams.caseId // ERROR!
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
type SearchParams = Promise<{ caseId?: string }>
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParams
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const caseId = getCaseIdFromParams(params)
|
||||
}
|
||||
```
|
||||
|
||||
## Server Components vs Client Components
|
||||
|
||||
### When to Use Server Components (Default)
|
||||
|
||||
Use server components (no `'use client'`) for:
|
||||
- Pages that fetch data
|
||||
- Pages that need SEO
|
||||
- Static content
|
||||
- Database queries
|
||||
|
||||
```typescript
|
||||
// Server Component (default)
|
||||
import { createServerClient } from '@/lib/supabase/server'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createServerClient()
|
||||
const { data } = await supabase.from('cases').select('*')
|
||||
|
||||
return <div>{/* Render data */}</div>
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Client Components
|
||||
|
||||
Use `'use client'` for:
|
||||
- Interactive UI (onClick, onChange, etc.)
|
||||
- React hooks (useState, useEffect, useContext)
|
||||
- Browser APIs (localStorage, window, etc.)
|
||||
- Third-party libraries that need client-side
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function InteractiveComponent() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return <button onClick={() => setCount(count + 1)}>{count}</button>
|
||||
}
|
||||
```
|
||||
|
||||
### Mixing Server and Client
|
||||
|
||||
**Pattern**: Server component fetches data, passes to client component
|
||||
|
||||
```typescript
|
||||
// app/page.tsx (Server Component)
|
||||
import { ClientComponent } from './client-component'
|
||||
|
||||
export default async function Page() {
|
||||
const data = await fetchData()
|
||||
|
||||
return <ClientComponent data={data} />
|
||||
}
|
||||
|
||||
// client-component.tsx (Client Component)
|
||||
'use client'
|
||||
|
||||
export function ClientComponent({ data }) {
|
||||
const [selected, setSelected] = useState(null)
|
||||
|
||||
return (
|
||||
<div onClick={() => setSelected(data)}>
|
||||
{/* Interactive UI */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Metadata API
|
||||
|
||||
Use the Metadata API for SEO:
|
||||
|
||||
```typescript
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Dashboard | CareBridge',
|
||||
description: 'Manage your care cases',
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <div>Dashboard</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Loading and Error States
|
||||
|
||||
### Loading States
|
||||
|
||||
Create `loading.tsx` next to `page.tsx`:
|
||||
|
||||
```typescript
|
||||
// app/dashboard/loading.tsx
|
||||
export default function Loading() {
|
||||
return <div>Loading dashboard...</div>
|
||||
}
|
||||
```
|
||||
|
||||
### Error Boundaries
|
||||
|
||||
Create `error.tsx` next to `page.tsx`:
|
||||
|
||||
```typescript
|
||||
// app/dashboard/error.tsx
|
||||
'use client' // Error boundaries must be client components
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong!</h2>
|
||||
<button onClick={reset}>Try again</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Data Fetching Patterns
|
||||
|
||||
### Server-Side Fetching (Preferred)
|
||||
|
||||
```typescript
|
||||
// In Server Component
|
||||
export default async function Page() {
|
||||
const supabase = await createServerClient()
|
||||
const { data } = await supabase.from('cases').select('*')
|
||||
|
||||
return <CasesList cases={data} />
|
||||
}
|
||||
```
|
||||
|
||||
### Client-Side Fetching (When Needed)
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function ClientDataFetcher() {
|
||||
const [data, setData] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/data')
|
||||
.then((res) => res.json())
|
||||
.then(setData)
|
||||
}, [])
|
||||
|
||||
if (!data) return <div>Loading...</div>
|
||||
|
||||
return <div>{/* Render data */}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Form Actions
|
||||
|
||||
Use Server Actions for forms:
|
||||
|
||||
```typescript
|
||||
// app/actions.ts
|
||||
'use server'
|
||||
|
||||
export async function createCase(formData: FormData) {
|
||||
const name = formData.get('name')
|
||||
|
||||
const supabase = await createServerClient()
|
||||
await supabase.from('cases').insert({ name })
|
||||
|
||||
revalidatePath('/dashboard')
|
||||
}
|
||||
|
||||
// app/page.tsx
|
||||
import { createCase } from './actions'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<form action={createCase}>
|
||||
<input name="name" />
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Mistake #1: Forgetting 'use client'
|
||||
|
||||
```typescript
|
||||
// WRONG - Will error because useState needs client
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Component() {
|
||||
const [count, setCount] = useState(0) // ERROR!
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Component() {
|
||||
const [count, setCount] = useState(0)
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #2: Not awaiting params
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export default function Page({ params }) {
|
||||
const { id } = params // ERROR in Next.js 15!
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
export default async function Page({ params }) {
|
||||
const { id } = await params
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #3: Using client-side hooks in server components
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
export default function Page() {
|
||||
const router = useRouter() // ERROR!
|
||||
}
|
||||
|
||||
// CORRECT - Add 'use client'
|
||||
'use client'
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter()
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #4: Importing server-only code in client components
|
||||
|
||||
```typescript
|
||||
// WRONG - Client component trying to use server-only code
|
||||
'use client'
|
||||
|
||||
import { createServerClient } from '@/lib/supabase/server' // ERROR!
|
||||
|
||||
export function Component() {
|
||||
const supabase = await createServerClient() // Won't work!
|
||||
}
|
||||
|
||||
// CORRECT - Use Server Action instead
|
||||
'use client'
|
||||
|
||||
import { getData } from './actions'
|
||||
|
||||
export function Component() {
|
||||
const handleClick = async () => {
|
||||
const data = await getData() // Calls server action
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Public vs Private
|
||||
|
||||
```typescript
|
||||
// ✅ Public (available in browser)
|
||||
const key = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
|
||||
// ✅ Private (server-only)
|
||||
const secret = process.env.STRIPE_SECRET_KEY // Only works in server components/actions
|
||||
```
|
||||
|
||||
### Accessing in Client Components
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Private env vars don't work in client
|
||||
'use client'
|
||||
|
||||
const secret = process.env.STRIPE_SECRET_KEY // undefined!
|
||||
|
||||
// ✅ CORRECT - Use public env vars
|
||||
'use client'
|
||||
|
||||
const publicKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
Next.js 15 has granular caching:
|
||||
|
||||
```typescript
|
||||
// Cache indefinitely (static data)
|
||||
const data = await fetch('https://api.example.com/data', {
|
||||
cache: 'force-cache',
|
||||
})
|
||||
|
||||
// Revalidate every hour
|
||||
const data = await fetch('https://api.example.com/data', {
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
// Never cache (dynamic data)
|
||||
const data = await fetch('https://api.example.com/data', {
|
||||
cache: 'no-store',
|
||||
})
|
||||
```
|
||||
|
||||
## Route Handlers (API Routes)
|
||||
|
||||
```typescript
|
||||
// app/api/route.ts
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get('id')
|
||||
|
||||
return Response.json({ id })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json()
|
||||
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Routes
|
||||
|
||||
```typescript
|
||||
// app/cases/[caseId]/page.tsx
|
||||
type Props = {
|
||||
params: Promise<{ caseId: string }>
|
||||
}
|
||||
|
||||
export default async function CasePage({ params }: Props) {
|
||||
const { caseId } = await params
|
||||
|
||||
return <div>Case: {caseId}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
Use for auth checks, redirects, etc.:
|
||||
|
||||
```typescript
|
||||
// middleware.ts
|
||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
|
||||
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
'/dashboard(.*)',
|
||||
'/cases(.*)',
|
||||
])
|
||||
|
||||
export default clerkMiddleware((auth, req) => {
|
||||
if (isProtectedRoute(req)) auth().protect()
|
||||
})
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
|
||||
}
|
||||
```
|
||||
331
references/stripe-integration.md
Normal file
331
references/stripe-integration.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Stripe Integration - CareBridge
|
||||
|
||||
## Overview
|
||||
|
||||
CareBridge uses Stripe for:
|
||||
- **SaaS Subscription**: $19.99/month platform access
|
||||
- **Concierge Packages**: 4 service packages (Essentials, Benefits, Concierge Plus, White-Glove)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pricing Configuration (Single Source of Truth)
|
||||
|
||||
**ALL pricing lives in `src/lib/config/concierge-pricing.ts`**
|
||||
|
||||
```typescript
|
||||
export const CONCIERGE_PACKAGES: Record<PackageType, ConciergePackage> = {
|
||||
essentials: {
|
||||
name: 'Essentials Package',
|
||||
priceRange: '$399 – $899',
|
||||
payment_type: 'one_time',
|
||||
pricing: {
|
||||
type: 'range', // Allows any price in range
|
||||
min: 39900, // $399 in cents
|
||||
max: 89900 // $899 in cents
|
||||
}
|
||||
},
|
||||
concierge_plus: {
|
||||
name: 'Concierge Plus',
|
||||
priceRange: '$249 – $499/month',
|
||||
payment_type: 'subscription',
|
||||
pricing: {
|
||||
type: 'tiers', // Predefined Stripe price IDs
|
||||
tiers: [
|
||||
{
|
||||
name: 'Light Support',
|
||||
price: 24900,
|
||||
priceId: process.env.NEXT_PUBLIC_STRIPE_CONCIERGE_PLUS_LIGHT_PRICE_ID,
|
||||
},
|
||||
// ... more tiers
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flexible Pricing System
|
||||
|
||||
CareBridge supports two pricing approaches:
|
||||
|
||||
1. **Tiered Pricing** (Concierge Plus, White-Glove)
|
||||
- Predefined Stripe price IDs
|
||||
- User selects from available tiers
|
||||
- Uses Stripe's price objects
|
||||
|
||||
2. **Range Pricing** (Essentials, Benefits)
|
||||
- Custom pricing within a range
|
||||
- Uses Stripe `price_data` for dynamic pricing
|
||||
- Staff can set exact price during booking
|
||||
|
||||
## Stripe Product Setup
|
||||
|
||||
### Automated Setup Script
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
./setup-carebridge-pricing.sh
|
||||
```
|
||||
|
||||
This creates:
|
||||
- SaaS subscription product ($19.99/mo)
|
||||
- Concierge Plus tiers (3 prices)
|
||||
- White-Glove tiers (3 prices)
|
||||
- Test customer
|
||||
- Generates .env.stripe with all IDs
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required in `.env.local`:
|
||||
|
||||
```bash
|
||||
# Stripe Keys
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# SaaS Subscription
|
||||
NEXT_PUBLIC_STRIPE_SAAS_MONTHLY_PRICE_ID=price_...
|
||||
|
||||
# Concierge Plus Tiers
|
||||
NEXT_PUBLIC_STRIPE_CONCIERGE_PLUS_LIGHT_PRICE_ID=price_...
|
||||
NEXT_PUBLIC_STRIPE_CONCIERGE_PLUS_STANDARD_PRICE_ID=price_...
|
||||
NEXT_PUBLIC_STRIPE_CONCIERGE_PLUS_PREMIUM_PRICE_ID=price_...
|
||||
|
||||
# White-Glove Tiers
|
||||
NEXT_PUBLIC_STRIPE_WHITE_GLOVE_STANDARD_PRICE_ID=price_...
|
||||
NEXT_PUBLIC_STRIPE_WHITE_GLOVE_PREMIUM_PRICE_ID=price_...
|
||||
NEXT_PUBLIC_STRIPE_WHITE_GLOVE_ENTERPRISE_PRICE_ID=price_...
|
||||
```
|
||||
|
||||
## Creating Checkout Sessions
|
||||
|
||||
### SaaS Subscription
|
||||
|
||||
```typescript
|
||||
// Fixed price - no user input needed
|
||||
const response = await fetch('/api/create-subscription-checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseId }), // Preserve case context
|
||||
})
|
||||
```
|
||||
|
||||
API route automatically uses the SaaS price ID from env vars.
|
||||
|
||||
### Concierge Packages
|
||||
|
||||
```typescript
|
||||
import { createConciergeCheckout } from '@/lib/actions/concierge-actions'
|
||||
|
||||
// For tiered pricing (Concierge Plus, White-Glove)
|
||||
const result = await createConciergeCheckout({
|
||||
package_type: 'concierge_plus',
|
||||
price_cents: 24900, // Must match a tier price
|
||||
display_name: 'Light Support',
|
||||
case_id: caseId,
|
||||
})
|
||||
|
||||
// For range pricing (Essentials, Benefits)
|
||||
const result = await createConciergeCheckout({
|
||||
package_type: 'essentials',
|
||||
price_cents: 50000, // Any value between min/max
|
||||
display_name: 'Essentials Package',
|
||||
case_id: caseId,
|
||||
})
|
||||
```
|
||||
|
||||
The action automatically:
|
||||
- Validates price is within allowed range/tiers
|
||||
- Creates Stripe checkout with correct mode (subscription vs payment)
|
||||
- Adds metadata for webhook routing
|
||||
- Returns checkout URL
|
||||
|
||||
## Webhook Handling
|
||||
|
||||
**File**: `src/app/api/webhooks/stripe/route.ts`
|
||||
|
||||
### Metadata-Driven Routing
|
||||
|
||||
Webhooks determine the type by checking `session.metadata.package_type`:
|
||||
|
||||
```typescript
|
||||
case 'checkout.session.completed': {
|
||||
if (session.metadata?.package_type) {
|
||||
// Concierge package purchase
|
||||
await handleConciergeCheckout(session)
|
||||
} else if (session.subscription) {
|
||||
// SaaS subscription
|
||||
await handleSaaSSubscriptionCheckout(session)
|
||||
}
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
### Required Metadata
|
||||
|
||||
Always include in checkout sessions:
|
||||
|
||||
```typescript
|
||||
metadata: {
|
||||
clerk_user_id: userId,
|
||||
package_type: 'concierge_plus', // For concierge packages
|
||||
payment_type: 'subscription', // one_time, project_based, or subscription
|
||||
price_display_name: 'Light Support',
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Events
|
||||
|
||||
Handle these events:
|
||||
|
||||
- `checkout.session.completed` - Create subscription/package record
|
||||
- `customer.subscription.updated` - Update subscription status
|
||||
- `customer.subscription.deleted` - Mark as cancelled
|
||||
|
||||
## Database Structure
|
||||
|
||||
### SaaS Subscriptions
|
||||
|
||||
Table: `subscriptions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscriptions (
|
||||
id UUID PRIMARY KEY,
|
||||
clerk_user_id TEXT NOT NULL,
|
||||
stripe_subscription_id VARCHAR(255),
|
||||
is_active BOOLEAN DEFAULT false, -- Simple boolean, no tiers
|
||||
status VARCHAR(50),
|
||||
current_period_end TIMESTAMPTZ,
|
||||
-- ...
|
||||
)
|
||||
```
|
||||
|
||||
### Concierge Packages
|
||||
|
||||
Table: `concierge_packages`
|
||||
|
||||
```sql
|
||||
CREATE TABLE concierge_packages (
|
||||
id UUID PRIMARY KEY,
|
||||
clerk_user_id TEXT NOT NULL,
|
||||
package_type VARCHAR(50) NOT NULL, -- essentials, benefits, etc.
|
||||
payment_type VARCHAR(20) NOT NULL, -- one_time, project_based, subscription
|
||||
stripe_payment_intent_id VARCHAR(255), -- For one-time
|
||||
stripe_subscription_id VARCHAR(255), -- For subscriptions
|
||||
price_paid_cents INT NOT NULL,
|
||||
price_display_name VARCHAR(100),
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
-- ...
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Local Webhook Testing
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start webhook listener
|
||||
stripe listen --forward-to localhost:3000/api/webhooks/stripe
|
||||
|
||||
# Copy the webhook secret (whsec_...) to .env.local as STRIPE_WEBHOOK_SECRET
|
||||
|
||||
# Terminal 2: Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Test Cards
|
||||
|
||||
- Success: `4242 4242 4242 4242`
|
||||
- Requires Auth: `4000 0025 0000 3155`
|
||||
- Declined: `4000 0000 0000 9995`
|
||||
|
||||
Expiry: Any future date | CVC: Any 3 digits | ZIP: Any 5 digits
|
||||
|
||||
### Testing Checklist
|
||||
|
||||
1. ✅ SaaS subscription checkout
|
||||
2. ✅ Concierge package with tiers
|
||||
3. ✅ Concierge package with custom price
|
||||
4. ✅ Webhook creates database records
|
||||
5. ✅ Success page displays correctly
|
||||
6. ✅ Billing page shows all subscriptions
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### ❌ Mistake #1: Not validating price
|
||||
|
||||
```typescript
|
||||
// WRONG - No validation
|
||||
await createConciergeCheckout({
|
||||
price_cents: 1000000, // Way above max!
|
||||
})
|
||||
|
||||
// CORRECT - Use the action, it validates automatically
|
||||
const result = await createConciergeCheckout({
|
||||
package_type: 'essentials',
|
||||
price_cents: 50000,
|
||||
display_name: 'Essentials',
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
// Handle validation error
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mistake #2: Hardcoding price IDs
|
||||
|
||||
```typescript
|
||||
// WRONG - Hardcoded
|
||||
const priceId = 'price_abc123'
|
||||
|
||||
// CORRECT - Use env vars
|
||||
const priceId = process.env.NEXT_PUBLIC_STRIPE_SAAS_MONTHLY_PRICE_ID
|
||||
```
|
||||
|
||||
### ❌ Mistake #3: Not including metadata
|
||||
|
||||
```typescript
|
||||
// WRONG - Webhook won't know how to route
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
line_items: [...],
|
||||
mode: 'subscription',
|
||||
})
|
||||
|
||||
// CORRECT - Include routing metadata
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
line_items: [...],
|
||||
mode: 'subscription',
|
||||
metadata: {
|
||||
clerk_user_id: userId,
|
||||
package_type: 'concierge_plus',
|
||||
payment_type: 'subscription',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### ❌ Mistake #4: Forgetting to await Stripe responses
|
||||
|
||||
```typescript
|
||||
// WRONG - Missing await
|
||||
const session = stripe.checkout.sessions.create({...})
|
||||
|
||||
// CORRECT
|
||||
const session = await stripe.checkout.sessions.create({...})
|
||||
```
|
||||
|
||||
## Changing Pricing
|
||||
|
||||
To change pricing (add tiers, adjust prices, etc.):
|
||||
|
||||
1. Update `src/lib/config/concierge-pricing.ts`
|
||||
2. Create new Stripe products/prices
|
||||
3. Update environment variables
|
||||
4. No backend code changes needed!
|
||||
|
||||
The flexible architecture supports any pricing changes without touching server actions or webhooks.
|
||||
|
||||
## Documentation
|
||||
|
||||
- `STRIPE-SETUP-GUIDE.md` - Complete setup instructions
|
||||
- `CAREBRIDGE-PRICING-IMPLEMENTATION.md` - Technical deep dive
|
||||
- `scripts/README.md` - Script documentation
|
||||
Reference in New Issue
Block a user