Compare commits

...

10 Commits

Author SHA1 Message Date
d16ac3d76e Add curl to Docker image for Swarm health checks
The runner stage needs curl for the health check command.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 13:35:57 +01:00
ef31ed3564 Add health endpoint for Swarm health checks
The /health route returns JSON status for Docker Swarm
to verify container health during deployment.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 13:27:25 +01:00
2e61f00ce8 Add n8n webhook URL build argument to Dockerfile
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 13:06:43 +01:00
1035683b56 Add agentic dashboard and phase tracking UI components
- Add project tabs with chat and dashboard views
- Add agentic dashboard with progress timeline
- Add phase indicator and progress timeline components
- Add activity feed and metric card components
- Add status badge component

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:40:19 +01:00
eff740704b Add settings page with profile, billing, notifications, security tabs
- Create comprehensive settings page with 4 tabs
- Profile: name editing with save functionality
- Billing: subscription display with Stripe portal link
- Notifications: toggle switches for email preferences
- Security: password change, 2FA placeholder, account deletion
- Add shadcn Switch component

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:37:42 +01:00
54e05173e0 Add phase persistence to Supabase
- Add phase_metadata column to projects table
- Update database types with phase_metadata field
- Implement useProjectPhase hook with persistence
- Add setPhaseStatus and addActivity functions
- Real-time subscription for phase updates

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:33:00 +01:00
38c081527b Add marketing layout for pricing page
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 00:51:10 +01:00
1ec6bd89c8 Add Stripe subscription system and n8n chat webhook
- Add Stripe SDK and subscription management
- Create checkout, webhook, and portal API routes
- Add pricing page with plan cards
- Create subscriptions table in Supabase
- Update database types for subscriptions
- Configure n8n webhook for AI chat responses

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 23:24:22 +01:00
53dbb0ed97 Add Mylder brand design system with warm amber accent
- Implement comprehensive design system with OKLCH colors
- Add warm stone neutrals replacing cold zinc grays
- Add amber brand color for CTAs and accents
- Update typography to Plus Jakarta Sans + JetBrains Mono
- Add brand variants to Button and Badge components
- Add utility classes: glass, shadow-brand, text-gradient-brand
- Update all pages to use semantic design tokens
- Add design system documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 16:19:06 +01:00
01739a50cd Switch from magic link to password-based authentication
- Update signup page with password and confirm password fields
- Update login page with password field
- Remove magic link/OTP flow entirely
- Auto-redirect to dashboard after successful auth

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 12:40:05 +01:00
38 changed files with 4934 additions and 262 deletions

View File

@@ -19,11 +19,13 @@ ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_N8N_WEBHOOK_URL
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_N8N_WEBHOOK_URL=$NEXT_PUBLIC_N8N_WEBHOOK_URL
RUN npm run build
@@ -33,6 +35,9 @@ WORKDIR /app
ENV NODE_ENV=production
# Install curl for health checks
RUN apk add --no-cache curl
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

380
docs/cloudflare-setup.md Normal file
View File

@@ -0,0 +1,380 @@
# Cloudflare Setup for Mylder Platform
## Overview
Cloudflare configuration for https://mylder.io - Next.js 16 frontend with self-hosted backend (Supabase, n8n) via Dokploy/Traefik.
**VPS:** 149.102.155.84
**Stack:** Next.js 16 → Traefik → Cloudflare CDN
## DNS Configuration
### Step 1: Add Domain to Cloudflare
1. Cloudflare Dashboard → Add Site → mylder.io
2. Update nameservers at domain registrar to Cloudflare's nameservers
### Step 2: DNS Records
| Type | Name | Target | Proxy Status | Notes |
|------|------|--------|--------------|-------|
| A | @ | 149.102.155.84 | Proxied (orange cloud) | Main site |
| A | www | 149.102.155.84 | Proxied | WWW redirect |
| A | dokploy | 149.102.155.84 | DNS Only (grey cloud) | Dokploy dashboard - skip CDN |
| A | supabase | 149.102.155.84 | DNS Only | Supabase API - skip CDN |
| A | n8n | 149.102.155.84 | DNS Only | n8n webhooks - skip CDN |
| A | gitea | 149.102.155.84 | DNS Only | Git push/pull - skip CDN |
| A | saas | 149.102.155.84 | DNS Only | Zulip chat - skip CDN |
**Key Principle:**
- **Proxied (CDN):** Frontend, static assets, public content
- **DNS Only:** Admin tools, APIs with authentication, real-time services
### Step 3: Subdomain Wildcard (Optional)
If you need dynamic subdomains (e.g., tenant.mylder.io):
```
CNAME * @ (Proxied)
```
## SSL/TLS Settings
### Encryption Mode: Full (Strict)
**Path:** SSL/TLS → Overview → Full (strict)
**Why:** Traefik already provides Let's Encrypt certificates. Full (strict) ensures end-to-end encryption:
```
Browser → Cloudflare (encrypted) → Traefik (encrypted) → App
```
### Edge Certificates
**Path:** SSL/TLS → Edge Certificates
- **Always Use HTTPS:** ON
- **Minimum TLS Version:** 1.2
- **Opportunistic Encryption:** ON
- **TLS 1.3:** ON
- **Automatic HTTPS Rewrites:** ON
- **Certificate Transparency Monitoring:** ON
### Origin Server
**Path:** SSL/TLS → Origin Server
No action needed - Traefik manages Let's Encrypt certificates automatically.
## Caching Rules
### Default Cache Levels
**Path:** Caching → Configuration
- **Caching Level:** Standard
- **Browser Cache TTL:** Respect Existing Headers
- **Always Online:** ON (serves stale content if origin is down)
### Cache Rules for Next.js
**Path:** Rules → Page Rules (legacy) or Cache Rules (new)**
#### Rule 1: Static Assets (Aggressive Caching)
```
URL Pattern: mylder.io/_next/static/*
Settings:
- Cache Level: Cache Everything
- Edge Cache TTL: 1 year
- Browser Cache TTL: 1 year
```
#### Rule 2: Images (Smart Caching)
```
URL Pattern: mylder.io/_next/image/*
Settings:
- Cache Level: Cache Everything
- Edge Cache TTL: 30 days
- Browser Cache TTL: 7 days
```
#### Rule 3: API Routes (No Cache)
```
URL Pattern: mylder.io/api/*
Settings:
- Cache Level: Bypass
```
#### Rule 4: Dynamic Pages (Smart Cache)
```
URL Pattern: mylder.io/*
Settings:
- Cache Level: Standard
- Edge Cache TTL: 2 hours
- Browser Cache TTL: 30 minutes
```
### Next.js Cache Headers
Ensure your Next.js app sets proper headers:
```typescript
// next.config.ts
const nextConfig = {
async headers() {
return [
{
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
source: '/_next/image/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=2592000, stale-while-revalidate=86400',
},
],
},
];
},
};
```
### Cache Purge Strategy
**Path:** Caching → Configuration → Purge Cache
- **On Deploy:** Purge Everything (via API or Dashboard)
- **On Content Update:** Purge by Tag/URL
- **Emergency:** Purge Everything button
**API Purge (for CI/CD):**
```bash
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
```
## Speed Optimization
### Auto Minify
**Path:** Speed → Optimization
- **JavaScript:** ON
- **CSS:** ON
- **HTML:** ON
### Brotli Compression
**Path:** Speed → Optimization
- **Brotli:** ON (better than gzip)
### Early Hints
**Path:** Speed → Optimization
- **Early Hints:** ON (preload critical resources)
### Rocket Loader
**Path:** Speed → Optimization
- **Rocket Loader:** OFF (can conflict with React hydration)
### Image Optimization
**Path:** Speed → Optimization → Image Resizing
- **Polish:** Lossless (or Lossy for smaller files)
- **Mirage:** ON (lazy loading for mobile)
- **Image Resizing:** Available on Pro+ plan
**Next.js Image Optimization:**
```typescript
// next.config.ts
const nextConfig = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 2592000, // 30 days
},
};
```
## Security Settings
### SSL/TLS
- **Always Use HTTPS:** ON
- **HSTS:** Enable with max-age=31536000, includeSubDomains
### Firewall (WAF)
**Path:** Security → WAF
#### Managed Rules (Free Tier)
- **Cloudflare Managed Ruleset:** ON
- **OWASP Core Ruleset:** ON
#### Rate Limiting (Pro+ Plan)
```yaml
Rule: API Protection
If: Request URL contains /api/
Then: Rate limit 100 requests per minute per IP
```
#### Bot Fight Mode (Free)
**Path:** Security → Bots
- **Bot Fight Mode:** ON
- **Super Bot Fight Mode:** Pro+ plan
### Security Level
**Path:** Security → Settings
- **Security Level:** Medium (balance between security and UX)
### Challenge Passage
**Path:** Security → Settings
- **Challenge Passage:** 30 minutes
## Network Settings
### HTTP/2 & HTTP/3
**Path:** Network
- **HTTP/2:** ON (default)
- **HTTP/3 (QUIC):** ON (faster connections)
- **0-RTT Connection Resumption:** ON
### WebSockets
**Path:** Network
- **WebSockets:** ON (required for real-time features)
### gRPC
**Path:** Network
- **gRPC:** OFF (not needed for standard Next.js)
## Analytics & Monitoring
### Web Analytics
**Path:** Analytics & Logs → Web Analytics
- Enable Cloudflare Web Analytics (privacy-friendly, no cookie banner needed)
### Performance Monitoring
- **Core Web Vitals:** Track LCP, FID, CLS
- **Page Load Time:** Monitor at edge
### Real User Monitoring (RUM)
Add Cloudflare Zaraz or custom analytics:
```html
<!-- Cloudflare Web Analytics -->
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
data-cf-beacon='{"token": "YOUR_TOKEN"}'></script>
```
## Subdomain Configuration
Since you have multiple services, ensure proper routing:
### Traefik Labels (Dokploy Docker Compose)
```yaml
services:
app:
labels:
- "traefik.enable=true"
- "traefik.http.routers.mylder.rule=Host(`mylder.io`) || Host(`www.mylder.io`)"
- "traefik.http.routers.mylder.tls=true"
- "traefik.http.routers.mylder.tls.certresolver=letsencrypt"
```
### WWW Redirect (Cloudflare)
**Path:** Rules → Redirect Rules
```
If: Hostname equals www.mylder.io
Then: Redirect to https://mylder.io$1 (301 permanent)
```
## Testing Checklist
### DNS Propagation
```bash
dig mylder.io
nslookup mylder.io
```
### SSL Certificate
```bash
curl -I https://mylder.io
# Check for: HTTP/2 200, cf-cache-status header
```
### Cache Verification
```bash
curl -I https://mylder.io/_next/static/[hash]/[file].js
# Look for: cf-cache-status: HIT
```
### Performance
```bash
# Lighthouse
npx lighthouse https://mylder.io --view
# WebPageTest
# Visit https://www.webpagetest.org
```
## Troubleshooting
### 521 Error (Web Server Down)
- Check Traefik is running: `docker ps | grep traefik`
- Check Dokploy deployment status
### 522 Error (Connection Timed Out)
- Verify firewall allows Cloudflare IPs
- Check VPS security groups
### 525 Error (SSL Handshake Failed)
- Ensure SSL/TLS mode is "Full (strict)"
- Verify Traefik has valid Let's Encrypt certificate
### Cache Not Working
- Check Cache-Control headers in response
- Verify Page Rules are active
- Clear Cloudflare cache and test
### Mixed Content Warnings
- Enable "Automatic HTTPS Rewrites"
- Update hardcoded http:// URLs to https://
## Cost Optimization
### Free Plan Limits
- 100k requests/day (~3M/month)
- Unlimited bandwidth
- Basic DDoS protection
- Shared SSL certificate
- 3 Page Rules
### When to Upgrade to Pro ($20/month)
- Need more than 3 Page Rules
- Want Image Optimization (Polish, Mirage)
- Need custom SSL certificate
- Require advanced WAF rules
- Want priority support
### Enterprise Features (for future)
- Cloudflare Workers (serverless at edge)
- Load Balancing
- Argo Smart Routing
- Custom WAF rules
## Next Steps
1. **Immediate:** DNS + SSL/TLS setup
2. **Week 1:** Caching rules + performance testing
3. **Month 1:** Monitor analytics, tune cache rules
4. **Quarter 1:** Consider Workers for edge logic (see cloudflare-workers-roadmap.md)
## References
- [Cloudflare DNS Docs](https://developers.cloudflare.com/dns/)
- [Next.js Cloudflare Deployment](https://nextjs.org/docs/app/building-your-application/deploying)
- [Cloudflare Cache API](https://developers.cloudflare.com/cache/)

View File

@@ -0,0 +1,591 @@
# Cloudflare Workers Roadmap for Mylder
## Overview
Edge computing strategy for Mylder platform using Cloudflare Workers, R2, D1, and KV.
**Philosophy:** Move compute closer to users, reduce VPS load, improve UX with sub-100ms responses.
## Current Architecture vs Edge-First
### Current (VPS-Centric)
```
User → Cloudflare CDN → VPS (149.102.155.84)
├── Next.js (Frontend)
├── Supabase (Auth, DB)
├── n8n (Automation)
└── Dokploy (Orchestration)
```
### Future (Edge-First)
```
User → Cloudflare Workers (Edge Logic)
├── Static Assets (CDN)
├── Edge Auth (Workers + KV)
├── Edge API (Workers + D1)
└── Origin (VPS for heavy compute)
├── Supabase (Primary DB)
└── n8n (Automation)
```
## Workers Use Cases
### 1. Edge Authentication (Priority: High)
**Problem:** Every auth check hits VPS → Supabase (100-300ms)
**Solution:** Cache JWT validation at edge
```typescript
// workers/auth-edge.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const token = request.headers.get('Authorization')?.split('Bearer ')[1];
if (!token) return new Response('Unauthorized', { status: 401 });
// Check KV cache for user session
const cached = await env.SESSIONS.get(token);
if (cached) {
return new Response(cached, {
headers: { 'X-Auth-Source': 'edge-cache' }
});
}
// Fallback to Supabase origin
const response = await fetch('https://supabase.mylder.io/auth/v1/user', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const user = await response.text();
await env.SESSIONS.put(token, user, { expirationTtl: 3600 }); // 1 hour
return new Response(user, {
headers: { 'X-Auth-Source': 'origin' }
});
}
return response;
}
};
```
**Benefits:**
- Auth checks: 300ms → 10ms (30x faster)
- Reduced Supabase load
- Better UX for logged-in users
**Trade-offs:**
- Session invalidation complexity
- KV storage costs ($0.50/1M reads)
### 2. API Rate Limiting (Priority: High)
**Problem:** DDoS/abuse protection on VPS is resource-intensive
**Solution:** Rate limit at edge before hitting origin
```typescript
// workers/rate-limiter.ts
import { RateLimiter } from '@cloudflare/workers-rate-limiter';
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const limiter = new RateLimiter({
namespace: env.RATE_LIMIT,
// 100 requests per 60 seconds per IP
limit: 100,
period: 60,
});
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
const { success } = await limiter.limit({ key: ip });
if (!success) {
return new Response('Rate limit exceeded', {
status: 429,
headers: { 'Retry-After': '60' }
});
}
// Forward to origin
return fetch(request);
}
};
```
**Benefits:**
- Block abuse before it hits VPS
- Per-IP, per-user, per-endpoint limits
- 0ms latency overhead
**Use Cases:**
- `/api/*` endpoints
- Login attempts
- Form submissions
- Search queries
### 3. A/B Testing at Edge (Priority: Medium)
**Problem:** Client-side A/B testing causes layout shift (poor CLS)
**Solution:** Server-side rendering with edge variants
```typescript
// workers/ab-test.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Check existing variant
let variant = request.headers.get('Cookie')?.match(/variant=([AB])/)?.[1];
if (!variant) {
// Assign 50/50 split
variant = Math.random() < 0.5 ? 'A' : 'B';
}
// Rewrite request to origin with variant
const originUrl = `${url.origin}${url.pathname}?variant=${variant}`;
const response = await fetch(originUrl, request);
// Set variant cookie
const headers = new Headers(response.headers);
headers.set('Set-Cookie', `variant=${variant}; Path=/; Max-Age=2592000`);
return new Response(response.body, {
status: response.status,
headers
});
}
};
```
**Benefits:**
- No layout shift (CLS = 0)
- Instant variant assignment
- Analytics at edge
**Use Cases:**
- Landing page variants
- Pricing page tests
- Feature flags
### 4. Edge Personalization (Priority: Medium)
**Problem:** Generic content for all users (low engagement)
**Solution:** Geo/device-specific content at edge
```typescript
// workers/personalize.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const country = request.cf?.country as string;
const device = request.headers.get('User-Agent')?.includes('Mobile')
? 'mobile'
: 'desktop';
// Fetch base HTML from origin
const response = await fetch(request);
const html = await response.text();
// Inject personalized content
const personalized = html
.replace('{{COUNTRY}}', country)
.replace('{{DEVICE}}', device)
.replace('{{CURRENCY}}', getCurrency(country));
return new Response(personalized, {
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'public, max-age=60, s-maxage=3600',
'Vary': 'CF-IPCountry, User-Agent'
}
});
}
};
function getCurrency(country: string): string {
const map: Record<string, string> = {
US: 'USD', GB: 'GBP', EU: 'EUR', DK: 'DKK'
};
return map[country] || 'USD';
}
```
**Benefits:**
- Geo-specific content (currency, language)
- Device-optimized markup
- Sub-50ms personalization
**Use Cases:**
- Pricing display
- Content localization
- Feature availability
### 5. Image Optimization & Resizing (Priority: Low)
**Problem:** Next.js Image Optimization on VPS is CPU-intensive
**Solution:** Cloudflare Images or Workers + R2
```typescript
// workers/image-optimize.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const imagePath = url.pathname.replace('/img/', '');
// Parse resize params
const width = url.searchParams.get('w') || '1920';
const quality = url.searchParams.get('q') || '80';
const format = url.searchParams.get('f') || 'webp';
// Fetch from R2
const object = await env.IMAGES.get(imagePath);
if (!object) return new Response('Not Found', { status: 404 });
// Resize at edge (requires paid Workers plan)
const resized = await fetch(`https://mylder.io/cdn-cgi/image/width=${width},quality=${quality},format=${format}/${imagePath}`);
return new Response(resized.body, {
headers: {
'Content-Type': `image/${format}`,
'Cache-Control': 'public, max-age=31536000, immutable'
}
});
}
};
```
**Benefits:**
- Offload VPS CPU
- Auto WebP/AVIF conversion
- Responsive images on-demand
**Trade-offs:**
- Requires Cloudflare Images ($5/month + $1/100k)
- Or R2 storage ($0.015/GB/month)
### 6. Edge API Caching (Priority: Medium)
**Problem:** Repeated API calls for same data
**Solution:** Smart caching with stale-while-revalidate
```typescript
// workers/api-cache.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
// Check cache
let response = await cache.match(cacheKey);
if (response) {
const age = Date.now() - new Date(response.headers.get('Date')!).getTime();
// If stale (>60s), revalidate in background
if (age > 60000) {
ctx.waitUntil(revalidate(request, cache, cacheKey));
}
return response;
}
// Fetch from origin
response = await fetch(request);
// Cache for 5 minutes
const cached = new Response(response.body, response);
cached.headers.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=3600');
ctx.waitUntil(cache.put(cacheKey, cached));
return response;
}
};
async function revalidate(request: Request, cache: Cache, key: Request) {
const fresh = await fetch(request);
await cache.put(key, fresh.clone());
}
```
**Benefits:**
- API responses: 200ms → 10ms
- Stale content served instantly
- Background revalidation
**Use Cases:**
- Public API endpoints
- User profiles
- Content feeds
## Cloudflare R2 (Object Storage)
### Use Cases for R2
**What:** S3-compatible object storage at edge (no egress fees)
#### 1. User Uploads
```typescript
// Store user avatars, documents
await env.UPLOADS.put(`users/${userId}/avatar.jpg`, file);
```
**Benefits:**
- $0.015/GB/month (10x cheaper than S3)
- No egress fees (free downloads)
- Global CDN distribution
**When to Use:**
- User-generated content (avatars, documents)
- Static assets (fonts, icons)
- Media files (videos, audio)
**When NOT to Use:**
- Frequent small writes (use D1 instead)
- Sub-10MB total storage (use KV instead)
#### 2. Static Asset Hosting
```typescript
// Move _next/static/* to R2
const asset = await env.STATIC_ASSETS.get(path);
return new Response(asset.body, {
headers: { 'Cache-Control': 'public, max-age=31536000, immutable' }
});
```
**Benefits:**
- Reduce VPS storage needs
- Faster global distribution
- Immutable content = perfect cache
#### 3. Backup Storage
```typescript
// Daily Supabase backups to R2
await env.BACKUPS.put(`supabase-${date}.sql.gz`, backup);
```
## Cloudflare D1 (Edge Database)
### Use Cases for D1
**What:** SQLite at edge (sub-10ms queries globally)
#### 1. Edge Metadata
```typescript
// Store user preferences, feature flags
const db = env.DB;
const result = await db.prepare(
'SELECT theme, locale FROM user_prefs WHERE user_id = ?'
).bind(userId).first();
```
**Benefits:**
- Read latency: 100ms (Supabase) → 5ms (D1)
- No VPS load for reads
- SQL interface (familiar)
**When to Use:**
- Read-heavy data (user settings, configs)
- Small datasets (<1GB)
- Geo-distributed reads
**When NOT to Use:**
- Write-heavy workloads (D1 eventual consistency)
- Complex joins (use Supabase instead)
- Primary data storage (Supabase is source of truth)
#### 2. Analytics/Metrics
```typescript
// Store page views, click events
await db.prepare(
'INSERT INTO analytics (page, user_id, timestamp) VALUES (?, ?, ?)'
).bind(page, userId, Date.now()).run();
```
**Benefits:**
- High-volume writes
- Real-time aggregations
- No Supabase quota impact
#### 3. Feature Flags
```typescript
// Enable/disable features per user
const flags = await db.prepare(
'SELECT * FROM feature_flags WHERE active = 1'
).all();
```
### D1 vs Supabase Decision Matrix
| Feature | D1 (Edge) | Supabase (VPS) |
|---------|-----------|----------------|
| Read Latency | 5-10ms | 100-300ms |
| Write Latency | 50-100ms | 100-300ms |
| Consistency | Eventual | Strong |
| Storage Limit | 1GB | 8GB+ |
| Cost | Free (5M reads/day) | Included in VPS |
| Use Case | Cached metadata | Primary data |
**Strategy:** D1 = edge cache, Supabase = source of truth
## Cloudflare KV (Key-Value Store)
### Use Cases for KV
**What:** Global key-value store (sub-1ms reads)
#### 1. Session Storage
```typescript
await env.SESSIONS.put(sessionId, userData, { expirationTtl: 3600 });
```
**Benefits:**
- Fastest storage option (<1ms reads)
- Auto-expiration (TTL)
- Global replication
**Cost:** $0.50/1M reads, $5/1M writes (generous free tier)
#### 2. Configuration/Secrets
```typescript
const apiKey = await env.CONFIG.get('stripe_public_key');
```
#### 3. Rate Limit Counters
```typescript
const count = await env.RATE_LIMIT.get(ip);
await env.RATE_LIMIT.put(ip, count + 1, { expirationTtl: 60 });
```
## Implementation Roadmap
### Phase 1: Foundation (Month 1)
**Goal:** Basic edge infrastructure
- [ ] Set up Cloudflare Workers project
- [ ] Deploy edge auth validator (Worker + KV)
- [ ] Implement rate limiting for `/api/*`
- [ ] Migrate static assets to R2
- [ ] Monitor performance impact
**Success Metrics:**
- Auth latency: <20ms (p95)
- Static asset cache hit rate: >95%
- VPS CPU load: -20%
### Phase 2: Optimization (Month 2-3)
**Goal:** Performance wins
- [ ] Deploy A/B testing worker
- [ ] Implement edge personalization
- [ ] Set up D1 for user preferences
- [ ] Create stale-while-revalidate API cache
- [ ] Image optimization via R2 + Workers
**Success Metrics:**
- LCP: <1.5s (p75)
- API latency: -50% for cached endpoints
- Global availability: 99.9%
### Phase 3: Advanced (Month 4+)
**Goal:** Edge-first architecture
- [ ] Move session management to edge (D1 + KV)
- [ ] Implement edge analytics (D1)
- [ ] Deploy feature flags system (D1)
- [ ] Create edge API gateway
- [ ] Multi-region failover
**Success Metrics:**
- Edge request ratio: >70%
- Origin load: -60%
- Global latency: <100ms (p95)
## Cost Analysis
### Free Tier Limits
- **Workers:** 100k requests/day
- **KV:** 100k reads/day, 1k writes/day
- **R2:** 10GB storage, 1M reads/month
- **D1:** 5M reads/day, 100k writes/day
### Paid Costs (Estimated for 1M users/month)
| Service | Usage | Cost/Month |
|---------|-------|------------|
| Workers | 100M requests | $5 (bundled) |
| KV | 50M reads | $25 |
| R2 | 100GB storage + 500M reads | $1.50 |
| D1 | Included in Workers | $0 |
| **Total** | | **~$30/month** |
**ROI:** Potential to downgrade VPS ($30/month savings) → break even
## Anti-Patterns to Avoid
### 1. Over-Caching
**Bad:** Cache everything at edge for 24 hours
**Good:** Cache static assets long, dynamic content short
### 2. Edge as Primary DB
**Bad:** Store all user data in D1
**Good:** D1 = cache, Supabase = source of truth
### 3. Synchronous Edge-Origin Calls
**Bad:** Worker → fetch origin → wait → respond
**Good:** Serve stale, revalidate in background
### 4. Ignoring Cold Starts
**Bad:** Assume Workers are instant
**Good:** Optimize bundle size (<1MB), minimize dependencies
### 5. Complex Business Logic at Edge
**Bad:** Move entire Next.js app to Workers
**Good:** Keep heavy compute on VPS, edge for lightweight tasks
## Monitoring & Debugging
### Workers Analytics
**Dashboard:** Workers & Pages → Analytics
- Request volume
- Success rate (2xx/3xx/4xx/5xx)
- CPU time (p50, p95, p99)
- Error logs
### Tail Workers (Real-Time Logs)
```bash
wrangler tail mylder-auth-edge
```
### Custom Metrics
```typescript
// workers/metrics.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const start = Date.now();
const response = await fetch(request);
const duration = Date.now() - start;
// Log to D1 analytics
ctx.waitUntil(
env.ANALYTICS.prepare(
'INSERT INTO metrics (endpoint, duration, status) VALUES (?, ?, ?)'
).bind(request.url, duration, response.status).run()
);
return response;
}
};
```
## Next Steps
1. **Prototype:** Deploy simple edge auth worker (1 day)
2. **Measure:** Compare edge vs origin performance (1 week)
3. **Iterate:** Expand to rate limiting, A/B testing (1 month)
4. **Optimize:** Move 70%+ traffic to edge (3 months)
## References
- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/)
- [R2 Storage](https://developers.cloudflare.com/r2/)
- [D1 Database](https://developers.cloudflare.com/d1/)
- [Workers Examples](https://developers.cloudflare.com/workers/examples/)
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)

View File

@@ -0,0 +1,753 @@
# Performance Checklist for Mylder Platform
## Core Web Vitals Targets
### Production Goals (75th Percentile)
| Metric | Target | Good | Needs Improvement | Poor |
|--------|--------|------|-------------------|------|
| **LCP** (Largest Contentful Paint) | <2.5s | <2.5s | 2.5s-4.0s | >4.0s |
| **FID** (First Input Delay) | <100ms | <100ms | 100ms-300ms | >300ms |
| **CLS** (Cumulative Layout Shift) | <0.1 | <0.1 | 0.1-0.25 | >0.25 |
| **INP** (Interaction to Next Paint) | <200ms | <200ms | 200ms-500ms | >500ms |
| **TTFB** (Time to First Byte) | <600ms | <800ms | 800ms-1800ms | >1800ms |
| **FCP** (First Contentful Paint) | <1.8s | <1.8s | 1.8s-3.0s | >3.0s |
### Current vs Target Performance
**Baseline (before optimization):**
```
LCP: ~3.5s → Target: <2.5s (30% improvement needed)
FID: ~150ms → Target: <100ms
CLS: ~0.15 → Target: <0.1
TTFB: ~800ms → Target: <600ms (edge caching)
```
### Measurement Tools
#### 1. Real User Monitoring (RUM)
```typescript
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next';
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<SpeedInsights />
<Analytics />
</body>
</html>
);
}
```
**Alternative:** Cloudflare Web Analytics (privacy-friendly, no cookies)
```html
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
data-cf-beacon='{"token": "YOUR_TOKEN"}'></script>
```
#### 2. Lab Testing
```bash
# Lighthouse (local)
npx lighthouse https://mylder.io --view
# WebPageTest (global)
# https://www.webpagetest.org
# Chrome DevTools
# DevTools → Performance → Record → Analyze
```
#### 3. Continuous Monitoring
```bash
# Lighthouse CI (in CI/CD pipeline)
npm install -g @lhci/cli
lhci autorun --collect.url=https://mylder.io
```
## Next.js Optimization Settings
### 1. Production Build Configuration
```typescript
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Enable React strict mode for better error handling
reactStrictMode: true,
// Optimize bundle size
swcMinify: true,
// Compress output
compress: true,
// Optimize images
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
minimumCacheTTL: 2592000, // 30 days
dangerouslyAllowSVG: false,
contentDispositionType: 'attachment',
remotePatterns: [
{
protocol: 'https',
hostname: 'supabase.mylder.io',
pathname: '/storage/v1/object/**',
},
],
},
// Optimize fonts (automatic optimization)
optimizeFonts: true,
// Experimental features
experimental: {
// Optimize CSS
optimizeCss: true,
// Tree shaking for smaller bundles
optimizePackageImports: ['@radix-ui/react-icons', 'lucide-react'],
},
// Custom headers for caching
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
],
},
{
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
source: '/_next/image/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=2592000, stale-while-revalidate=86400',
},
],
},
];
},
};
export default nextConfig;
```
### 2. Bundle Analyzer
```bash
# Install
npm install -D @next/bundle-analyzer
# next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer';
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withBundleAnalyzer(nextConfig);
# Run analysis
ANALYZE=true npm run build
```
**Target:** Total bundle size <500KB (gzipped)
### 3. Code Splitting Strategies
#### Dynamic Imports
```typescript
// app/dashboard/page.tsx
import dynamic from 'next/dynamic';
// Lazy load heavy components
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Client-side only if needed
});
export default function Dashboard() {
return <HeavyChart data={data} />;
}
```
#### Route-Based Splitting
```typescript
// Automatic with App Router
app/
dashboard/ # Separate chunk
settings/ # Separate chunk
(marketing)/ # Separate chunk (route group)
```
#### Vendor Splitting
```typescript
// next.config.ts
experimental: {
optimizePackageImports: [
'@radix-ui/react-icons',
'lucide-react',
'date-fns',
],
},
```
### 4. Server Components (Default)
```typescript
// app/page.tsx (Server Component by default)
export default async function Home() {
// Fetch at build time or request time
const data = await fetch('https://api.mylder.io/data', {
next: { revalidate: 3600 } // ISR: 1 hour cache
});
return <div>{/* No client-side JS for this component */}</div>;
}
```
**Benefits:**
- 0KB JavaScript sent to client
- Faster initial page load
- Better SEO
### 5. Client Components (Opt-In)
```typescript
// components/InteractiveButton.tsx
'use client';
import { useState } from 'react';
export default function InteractiveButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
```
**Rule:** Only use 'use client' when you need:
- Interactivity (useState, useEffect)
- Browser APIs (localStorage, window)
- Event handlers (onClick, onSubmit)
## Image Optimization
### 1. Next.js Image Component
```typescript
import Image from 'next/image';
// Optimized image with automatic sizing
<Image
src="/hero.jpg"
alt="Hero image"
width={1920}
height={1080}
priority // For LCP images
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/..." // Generated at build
/>
// Responsive image
<Image
src="/hero.jpg"
alt="Hero image"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
priority
/>
```
### 2. Image Format Priority
1. **AVIF** (best compression, 50% smaller than JPEG)
2. **WebP** (wide support, 30% smaller than JPEG)
3. **JPEG/PNG** (fallback)
### 3. Cloudflare Image Optimization
```typescript
// next.config.ts
images: {
loader: 'custom',
loaderFile: './lib/cloudflare-image-loader.ts',
}
// lib/cloudflare-image-loader.ts
export default function cloudflareLoader({ src, width, quality }) {
const params = [`width=${width}`, `quality=${quality || 75}`, 'format=auto'];
return `https://mylder.io/cdn-cgi/image/${params.join(',')}/${src}`;
}
```
### 4. Lazy Loading Images
```typescript
// Non-critical images (below fold)
<Image
src="/feature.jpg"
alt="Feature"
width={800}
height={600}
loading="lazy" // Default, explicit here
/>
// Critical images (above fold, LCP)
<Image
src="/hero.jpg"
alt="Hero"
width={1920}
height={1080}
priority // Preload immediately
/>
```
### 5. Responsive Images Checklist
- [ ] Use Next.js Image component (automatic optimization)
- [ ] Set explicit width/height (prevent CLS)
- [ ] Use `priority` for LCP images
- [ ] Use `loading="lazy"` for below-fold images
- [ ] Provide blur placeholder for better UX
- [ ] Compress images before upload (80-85% quality)
- [ ] Use modern formats (AVIF, WebP)
## Font Optimization
### 1. Next.js Font Optimization (Built-In)
```typescript
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Prevent invisible text flash
variable: '--font-inter',
preload: true,
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
weight: ['400', '700'], // Only load needed weights
});
export default function RootLayout({ children }) {
return (
<html className={`${inter.variable} ${robotoMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
```
**Benefits:**
- Self-hosted fonts (no external requests)
- Automatic subset optimization
- Zero layout shift (font-display: swap)
### 2. Custom Fonts
```typescript
import localFont from 'next/font/local';
const myFont = localFont({
src: './fonts/MyFont.woff2',
display: 'swap',
variable: '--font-my-font',
});
```
### 3. Font Loading Best Practices
- [ ] Use only 2-3 font families max
- [ ] Load only needed weights (400, 700)
- [ ] Use `font-display: swap`
- [ ] Preload critical fonts
- [ ] Self-host fonts (no Google Fonts CDN)
## CSS Optimization
### 1. Tailwind CSS Production Config
```typescript
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
],
theme: {
extend: {},
},
// Remove unused styles in production
future: {
hoverOnlyWhenSupported: true, // Better mobile UX
},
};
export default config;
```
### 2. Critical CSS
Next.js automatically inlines critical CSS for first paint.
### 3. CSS Module Best Practices
```typescript
// Use CSS Modules for component-specific styles
import styles from './Button.module.css';
export function Button() {
return <button className={styles.button}>Click me</button>;
}
```
## JavaScript Optimization
### 1. Tree Shaking
```typescript
// Good: Import only what you need
import { format } from 'date-fns';
// Bad: Import entire library
import * as dateFns from 'date-fns';
```
### 2. Remove Unused Dependencies
```bash
# Analyze bundle
npx depcheck
# Remove unused
npm uninstall unused-package
```
### 3. Use Smaller Alternatives
| Heavy Package | Lightweight Alternative | Savings |
|---------------|------------------------|---------|
| Moment.js (232KB) | date-fns (13KB) | 95% |
| Lodash (70KB) | Lodash-es (24KB) | 66% |
| Axios (30KB) | Fetch API (native) | 100% |
| React Icons (full) | Lucide React (tree-shakeable) | 80% |
### 4. Debounce/Throttle Heavy Operations
```typescript
// components/Search.tsx
'use client';
import { useState, useCallback } from 'react';
import { debounce } from 'lodash-es/debounce';
export function Search() {
const [query, setQuery] = useState('');
const handleSearch = useCallback(
debounce(async (value: string) => {
const results = await fetch(`/api/search?q=${value}`);
// Handle results
}, 300),
[]
);
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
```
## Caching Strategy
### 1. Static Generation (Fastest)
```typescript
// app/blog/page.tsx
export const revalidate = false; // Static at build time
export default async function Blog() {
const posts = await fetchPosts();
return <PostList posts={posts} />;
}
```
### 2. Incremental Static Regeneration (ISR)
```typescript
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Revalidate every hour
export async function generateStaticParams() {
const posts = await fetchPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function Post({ params }) {
const post = await fetchPost(params.slug);
return <Article post={post} />;
}
```
### 3. Server-Side Rendering (SSR)
```typescript
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // Always server-render
export default async function Dashboard() {
const data = await fetchUserData(); // Per-request
return <DashboardContent data={data} />;
}
```
### 4. Client-Side Caching
```typescript
// Use SWR or React Query for client-side data fetching
'use client';
import useSWR from 'swr';
export function Profile() {
const { data, error } = useSWR('/api/user', fetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
refreshInterval: 60000, // 1 minute
});
if (error) return <div>Error</div>;
if (!data) return <div>Loading...</div>;
return <div>{data.name}</div>;
}
```
### 5. HTTP Cache Headers
```typescript
// app/api/data/route.ts
export async function GET() {
const data = await fetchData();
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
},
});
}
```
## Cache Invalidation Strategy
### 1. On-Demand Revalidation
```typescript
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const { path, tag } = await request.json();
if (path) {
revalidatePath(path);
}
if (tag) {
revalidateTag(tag);
}
return Response.json({ revalidated: true });
}
```
### 2. Webhook Triggers (n8n)
```typescript
// n8n workflow: CMS Update → POST /api/revalidate
{
"path": "/blog",
"tag": "posts"
}
```
### 3. Cloudflare Cache Purge
```bash
# On deploy (CI/CD)
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
```
### 4. Selective Purge
```bash
# Purge specific URLs
--data '{"files":["https://mylder.io/blog","https://mylder.io/about"]}'
# Purge by tag
--data '{"tags":["blog-posts"]}'
```
## Performance Testing Workflow
### 1. Pre-Deploy Checks
```bash
# Run before every deployment
npm run build # Must succeed
npm run lint # No errors
npm run typecheck # No type errors
# Optional but recommended
ANALYZE=true npm run build # Check bundle size
npx lighthouse https://staging.mylder.io --view
```
### 2. Post-Deploy Verification
```bash
# Production smoke tests
curl -I https://mylder.io # Check TTFB
curl -I https://mylder.io/_next/static/[hash]/[file].js # Check caching
# Full performance audit
npx lighthouse https://mylder.io --view
# Real user monitoring
# Check Cloudflare Analytics or Vercel Analytics dashboard
```
### 3. Continuous Monitoring
```bash
# Set up alerts in Cloudflare/monitoring tool
- LCP > 3s → Alert
- Error rate > 1% → Alert
- TTFB > 1s → Alert
```
## Quick Wins Checklist
### Immediate (1 day)
- [ ] Enable Cloudflare CDN (see cloudflare-setup.md)
- [ ] Add proper cache headers to static assets
- [ ] Use Next.js Image component everywhere
- [ ] Enable `reactStrictMode` and `swcMinify`
- [ ] Remove unused dependencies
### Short-term (1 week)
- [ ] Implement ISR for blog/content pages
- [ ] Lazy load heavy components
- [ ] Optimize images (compress, convert to WebP/AVIF)
- [ ] Set up bundle analyzer
- [ ] Add loading states and skeletons (improve perceived performance)
### Medium-term (1 month)
- [ ] Implement edge caching for auth (see cloudflare-workers-roadmap.md)
- [ ] Set up real user monitoring (RUM)
- [ ] Optimize third-party scripts (defer/async)
- [ ] Implement stale-while-revalidate for APIs
- [ ] Add Lighthouse CI to deployment pipeline
### Long-term (3 months)
- [ ] Move 70% traffic to edge (Cloudflare Workers)
- [ ] Implement edge personalization
- [ ] Set up A/B testing at edge
- [ ] Achieve Core Web Vitals targets consistently
- [ ] Reduce VPS load by 60%+
## Common Performance Issues & Fixes
### Issue: High LCP (>4s)
**Causes:**
- Large hero image not optimized
- Server response time (TTFB) too high
- Render-blocking resources
**Fixes:**
- Use Next.js Image with `priority` for LCP image
- Enable Cloudflare CDN for faster TTFB
- Inline critical CSS
- Preload fonts
### Issue: High CLS (>0.25)
**Causes:**
- Images without width/height
- Web fonts loading late
- Dynamic content injections
**Fixes:**
- Set explicit dimensions on images
- Use `font-display: swap`
- Reserve space for dynamic content
- Avoid inserting content above existing content
### Issue: High FID/INP (>300ms)
**Causes:**
- Heavy JavaScript execution
- Long tasks blocking main thread
- Unoptimized event handlers
**Fixes:**
- Code split large bundles
- Debounce/throttle event handlers
- Use Web Workers for heavy computation
- Optimize third-party scripts
### Issue: Slow TTFB (>1s)
**Causes:**
- Slow server response
- No CDN
- Database queries during render
**Fixes:**
- Enable Cloudflare CDN
- Use ISR instead of SSR
- Move database queries to edge (D1)
- Implement caching layers
## Monitoring Dashboard Setup
### Cloudflare Web Analytics
```html
<!-- Add to app/layout.tsx <head> -->
<script defer src='https://static.cloudflareinsights.com/beacon.min.js'
data-cf-beacon='{"token": "YOUR_TOKEN"}'></script>
```
### Custom Performance Monitoring
```typescript
// app/layout.tsx
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export function PerformanceMonitor() {
const pathname = usePathname();
useEffect(() => {
// Measure Web Vitals
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(console.log);
getFID(console.log);
getFCP(console.log);
getLCP(console.log);
getTTFB(console.log);
});
}, [pathname]);
return null;
}
```
## Resources
- [Next.js Performance Docs](https://nextjs.org/docs/app/building-your-application/optimizing)
- [Web.dev Core Web Vitals](https://web.dev/vitals/)
- [Lighthouse CI](https://github.com/GoogleChrome/lighthouse-ci)
- [Cloudflare Performance](https://developers.cloudflare.com/fundamentals/speed/)

235
package-lock.json generated
View File

@@ -14,18 +14,22 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@stripe/stripe-js": "^8.5.3",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.87.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.561.0",
"motion": "^12.23.26",
"next": "16.0.10",
"next-themes": "^0.4.6",
"react": "19.2.1",
"react-dom": "19.2.1",
"sonner": "^2.0.7",
"stripe": "^20.0.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
@@ -2296,6 +2300,91 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
@@ -2485,6 +2574,21 @@
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
@@ -3925,7 +4029,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3939,7 +4042,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4156,6 +4258,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4250,7 +4362,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -4362,7 +4473,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4372,7 +4482,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4410,7 +4519,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -5054,11 +5162,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -5119,7 +5253,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -5153,7 +5286,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -5241,7 +5373,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5313,7 +5444,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5342,7 +5472,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -6343,7 +6472,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6396,6 +6524,47 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.26.tgz",
"integrity": "sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.23.26",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6555,7 +6724,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -6862,6 +7030,21 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -7305,7 +7488,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -7325,7 +7507,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -7342,7 +7523,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -7361,7 +7541,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -7553,6 +7732,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "20.0.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.0.0.tgz",
"integrity": "sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==",
"license": "MIT",
"dependencies": {
"qs": "^6.11.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",

View File

@@ -15,18 +15,22 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@stripe/stripe-js": "^8.5.3",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.87.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.561.0",
"motion": "^12.23.26",
"next": "16.0.10",
"next-themes": "^0.4.6",
"react": "19.2.1",
"react-dom": "19.2.1",
"sonner": "^2.0.7",
"stripe": "^20.0.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {

View File

@@ -2,17 +2,19 @@
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Bot, Loader2, Mail } from 'lucide-react'
import { Bot, Loader2 } from 'lucide-react'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isSent, setIsSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleLogin = async (e: React.FormEvent) => {
@@ -22,11 +24,9 @@ export default function LoginPage() {
const supabase = createClient()
const { error } = await supabase.auth.signInWithOtp({
const { error } = await supabase.auth.signInWithPassword({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
password,
})
if (error) {
@@ -35,46 +35,19 @@ export default function LoginPage() {
return
}
setIsSent(true)
setIsLoading(false)
}
if (isSent) {
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Mail className="h-6 w-6 text-primary" />
</div>
<CardTitle>Check your email</CardTitle>
<CardDescription>
We sent a magic link to <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-zinc-500">
Click the link in your email to sign in. If you don&apos;t see it, check your spam folder.
</CardContent>
<CardFooter className="flex justify-center">
<Button variant="ghost" onClick={() => setIsSent(false)}>
Try a different email
</Button>
</CardFooter>
</Card>
</div>
)
router.push('/dashboard')
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<Link href="/" className="inline-flex items-center justify-center gap-2 mb-4">
<Bot className="h-8 w-8 text-primary" />
<Bot className="h-8 w-8 text-brand" />
<span className="text-xl font-bold">Mylder</span>
</Link>
<CardTitle>Welcome back</CardTitle>
<CardDescription>Sign in to your account with a magic link</CardDescription>
<CardDescription>Sign in to your account</CardDescription>
</CardHeader>
<form onSubmit={handleLogin}>
<CardContent className="space-y-4">
@@ -90,24 +63,36 @@ export default function LoginPage() {
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
<p className="text-sm text-destructive">{error}</p>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
<Button type="submit" variant="brand" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending link...
Signing in...
</>
) : (
'Send Magic Link'
'Sign In'
)}
</Button>
<p className="text-sm text-center text-zinc-500">
<p className="text-sm text-center text-muted-foreground">
Don&apos;t have an account?{' '}
<Link href="/signup" className="text-primary hover:underline">
<Link href="/signup" className="text-brand hover:underline">
Sign up
</Link>
</p>

View File

@@ -2,31 +2,45 @@
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Bot, Loader2, Mail } from 'lucide-react'
import { Bot, Loader2 } from 'lucide-react'
export default function SignupPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [fullName, setFullName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isSent, setIsSent] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters')
return
}
setIsLoading(true)
const supabase = createClient()
const { error } = await supabase.auth.signInWithOtp({
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
data: {
full_name: fullName,
},
@@ -39,42 +53,15 @@ export default function SignupPage() {
return
}
setIsSent(true)
setIsLoading(false)
}
if (isSent) {
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Mail className="h-6 w-6 text-primary" />
</div>
<CardTitle>Check your email</CardTitle>
<CardDescription>
We sent a magic link to <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="text-center text-sm text-zinc-500">
Click the link in your email to complete your signup. If you don&apos;t see it, check your spam folder.
</CardContent>
<CardFooter className="flex justify-center">
<Button variant="ghost" onClick={() => setIsSent(false)}>
Try a different email
</Button>
</CardFooter>
</Card>
</div>
)
router.push('/dashboard')
}
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950 px-4">
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<Link href="/" className="inline-flex items-center justify-center gap-2 mb-4">
<Bot className="h-8 w-8 text-primary" />
<Bot className="h-8 w-8 text-brand" />
<span className="text-xl font-bold">Mylder</span>
</Link>
<CardTitle>Create your account</CardTitle>
@@ -106,24 +93,48 @@ export default function SignupPage() {
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
<p className="text-sm text-destructive">{error}</p>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isLoading}>
<Button type="submit" variant="brand" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating account...
</>
) : (
'Get Started Free'
'Create Account'
)}
</Button>
<p className="text-sm text-center text-zinc-500">
<p className="text-sm text-center text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
<Link href="/login" className="text-brand hover:underline">
Log in
</Link>
</p>

View File

@@ -20,11 +20,11 @@ export default async function DashboardPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white">Projects</h1>
<p className="text-zinc-500">Manage your AI-powered development projects</p>
<h1 className="text-2xl font-bold text-foreground">Projects</h1>
<p className="text-muted-foreground">Manage your AI-powered development projects</p>
</div>
<Link href="/projects/new">
<Button className="gap-2">
<Button variant="brand" className="gap-2">
<Plus className="h-4 w-4" />
New Project
</Button>
@@ -35,11 +35,11 @@ export default async function DashboardPage() {
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project) => (
<Link key={project.id} href={`/projects/${project.id}`}>
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<Card className="h-full hover:border-brand/50 hover:shadow-md transition-all cursor-pointer">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-lg">{project.name}</CardTitle>
<Badge variant={project.status === 'active' ? 'default' : 'secondary'}>
<Badge variant={project.status === 'active' ? 'brand' : 'secondary'}>
{project.status}
</Badge>
</div>
@@ -48,7 +48,7 @@ export default async function DashboardPage() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-zinc-500">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
{project.tech_stack && project.tech_stack.length > 0 && (
<div className="flex gap-1">
{project.tech_stack.slice(0, 3).map((tech: string) => (
@@ -59,7 +59,7 @@ export default async function DashboardPage() {
</div>
)}
</div>
<div className="flex items-center gap-1 text-xs text-zinc-400 mt-4">
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-4">
<Clock className="h-3 w-3" />
Updated {new Date(project.updated_at).toLocaleDateString()}
</div>
@@ -71,17 +71,17 @@ export default async function DashboardPage() {
) : (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<FolderOpen className="h-8 w-8 text-zinc-400" />
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<FolderOpen className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white mb-2">
<h3 className="text-lg font-semibold text-foreground mb-2">
No projects yet
</h3>
<p className="text-zinc-500 text-center mb-6 max-w-sm">
<p className="text-muted-foreground text-center mb-6 max-w-sm">
Create your first project to start building with AI assistance.
</p>
<Link href="/projects/new">
<Button className="gap-2">
<Button variant="brand" className="gap-2">
<Plus className="h-4 w-4" />
Create Your First Project
</Button>

View File

@@ -1,11 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import { ProjectChat } from '@/components/chat/project-chat'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'
import { ArrowLeft, Settings } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { Database } from '@/types/database'
import { ProjectTabs } from './project-tabs'
type Project = Database['public']['Tables']['projects']['Row']
type Message = Database['public']['Tables']['messages']['Row']
@@ -24,7 +24,6 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
notFound()
}
// Get messages for this project
const { data: messages } = await supabase
.from('messages')
.select('*')
@@ -33,22 +32,21 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
return (
<div className="h-[calc(100vh-4rem)] flex flex-col">
{/* Project Header */}
<div className="border-b bg-white dark:bg-zinc-950 px-4 py-3">
<div className="border-b bg-card px-4 py-3">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/dashboard" className="text-zinc-500 hover:text-zinc-900 dark:hover:text-white">
<Link href="/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">{project.name}</h1>
<Badge variant={project.status === 'active' ? 'default' : 'secondary'}>
<Badge variant={project.status === 'active' ? 'brand' : 'secondary'}>
{project.status}
</Badge>
</div>
{project.description && (
<p className="text-sm text-zinc-500">{project.description}</p>
<p className="text-sm text-muted-foreground">{project.description}</p>
)}
</div>
</div>
@@ -58,10 +56,11 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
</div>
</div>
{/* Chat Interface */}
<div className="flex-1 overflow-hidden">
<ProjectChat projectId={id} initialMessages={messages || []} />
</div>
<ProjectTabs
projectId={id}
projectName={project.name}
initialMessages={messages || []}
/>
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ProjectChat } from '@/components/chat/project-chat'
import { AgenticDashboard } from '@/components/dashboard/agentic-dashboard'
import { MessageSquare, Workflow } from 'lucide-react'
import type { Database } from '@/types/database'
type Message = Database['public']['Tables']['messages']['Row']
interface ProjectTabsProps {
projectId: string
projectName: string
initialMessages: Message[]
}
export function ProjectTabs({ projectId, projectName, initialMessages }: ProjectTabsProps) {
return (
<Tabs defaultValue="chat" className="flex-1 flex flex-col overflow-hidden">
<div className="border-b bg-card/50 px-4">
<div className="max-w-7xl mx-auto">
<TabsList className="h-10 bg-transparent p-0 gap-4">
<TabsTrigger
value="chat"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
>
<MessageSquare className="w-4 h-4 mr-2" />
Chat
</TabsTrigger>
<TabsTrigger
value="workflow"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-foreground rounded-none px-1 pb-3 pt-2"
>
<Workflow className="w-4 h-4 mr-2" />
Workflow
</TabsTrigger>
</TabsList>
</div>
</div>
<TabsContent value="chat" className="flex-1 overflow-hidden m-0 data-[state=inactive]:hidden">
<ProjectChat projectId={projectId} initialMessages={initialMessages} />
</TabsContent>
<TabsContent value="workflow" className="flex-1 overflow-auto m-0 p-6 data-[state=inactive]:hidden">
<div className="max-w-7xl mx-auto">
<AgenticDashboard
projectId={projectId}
projectName={projectName}
/>
</div>
</TabsContent>
</Tabs>
)
}

View File

@@ -89,7 +89,7 @@ export default function NewProjectPage() {
return (
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Link href="/dashboard" className="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-900 dark:hover:text-white mb-6">
<Link href="/dashboard" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors mb-6">
<ArrowLeft className="h-4 w-4" />
Back to Projects
</Link>
@@ -125,9 +125,9 @@ export default function NewProjectPage() {
/>
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
<p className="text-sm text-destructive">{error}</p>
)}
<Button type="submit" className="w-full" disabled={isLoading || !name}>
<Button type="submit" variant="brand" className="w-full" disabled={isLoading || !name}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />

View File

@@ -0,0 +1,384 @@
'use client'
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Separator } from '@/components/ui/separator'
import { User, CreditCard, Bell, Shield, Loader2, Check, ExternalLink } from 'lucide-react'
import type { Database } from '@/types/database'
type Profile = Database['public']['Tables']['profiles']['Row']
type Subscription = Database['public']['Tables']['subscriptions']['Row']
export default function SettingsPage() {
const [profile, setProfile] = useState<Profile | null>(null)
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [formData, setFormData] = useState({
full_name: '',
email: '',
})
const [preferences, setPreferences] = useState({
emailNotifications: true,
marketingEmails: false,
weeklyDigest: true,
})
const supabase = createClient()
useEffect(() => {
async function loadData() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [profileRes, subRes] = await Promise.all([
(supabase as any).from('profiles').select('*').eq('id', user.id).single(),
(supabase as any).from('subscriptions').select('*').eq('user_id', user.id).single(),
])
if (profileRes.data) {
setProfile(profileRes.data)
setFormData({
full_name: profileRes.data.full_name || '',
email: profileRes.data.email || '',
})
}
if (subRes.data) {
setSubscription(subRes.data)
}
setLoading(false)
}
loadData()
}, [supabase])
const handleSaveProfile = async () => {
if (!profile) return
setSaving(true)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error } = await (supabase as any)
.from('profiles')
.update({ full_name: formData.full_name })
.eq('id', profile.id)
setSaving(false)
if (!error) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
}
const handleManageBilling = async () => {
const response = await fetch('/api/stripe/portal', {
method: 'POST',
})
const data = await response.json()
if (data.url) {
window.location.href = data.url
}
}
if (loading) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</div>
)
}
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage your account and preferences</p>
</div>
<Tabs defaultValue="profile" className="space-y-6">
<TabsList className="grid w-full grid-cols-4 lg:w-auto lg:inline-grid">
<TabsTrigger value="profile" className="gap-2">
<User className="h-4 w-4" />
<span className="hidden sm:inline">Profile</span>
</TabsTrigger>
<TabsTrigger value="billing" className="gap-2">
<CreditCard className="h-4 w-4" />
<span className="hidden sm:inline">Billing</span>
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<Bell className="h-4 w-4" />
<span className="hidden sm:inline">Notifications</span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Shield className="h-4 w-4" />
<span className="hidden sm:inline">Security</span>
</TabsTrigger>
</TabsList>
<TabsContent value="profile">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your personal information</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
placeholder="Enter your name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
value={formData.email}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Contact support to change your email
</p>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Account Role</p>
<p className="text-sm text-muted-foreground">
Your current account permissions
</p>
</div>
<Badge variant={profile?.role === 'admin' ? 'brand' : 'secondary'}>
{profile?.role || 'user'}
</Badge>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveProfile} disabled={saving}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : saved ? (
<Check className="h-4 w-4 mr-2" />
) : null}
{saved ? 'Saved!' : 'Save Changes'}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="billing">
<Card>
<CardHeader>
<CardTitle>Subscription & Billing</CardTitle>
<CardDescription>Manage your subscription and payment methods</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<div className="flex items-center gap-2">
<p className="font-medium">Current Plan</p>
<Badge variant="brand">{subscription?.plan || 'Free'}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{subscription?.status === 'active' ? (
<>
Renews on{' '}
{subscription.current_period_end
? new Date(subscription.current_period_end).toLocaleDateString()
: 'N/A'}
</>
) : (
'Upgrade to unlock more features'
)}
</p>
</div>
{subscription?.plan === 'free' ? (
<Button variant="brand" asChild>
<a href="/pricing">Upgrade Plan</a>
</Button>
) : (
<Button variant="outline" onClick={handleManageBilling}>
<ExternalLink className="h-4 w-4 mr-2" />
Manage Billing
</Button>
)}
</div>
<Separator />
<div>
<h4 className="font-medium mb-4">Plan Features</h4>
<div className="grid gap-3 sm:grid-cols-2">
{subscription?.plan === 'free' ? (
<>
<PlanFeature label="Projects" value="1" />
<PlanFeature label="AI Messages" value="50/month" />
<PlanFeature label="Templates" value="Basic" />
<PlanFeature label="Support" value="Community" />
</>
) : subscription?.plan === 'pro' ? (
<>
<PlanFeature label="Projects" value="Unlimited" />
<PlanFeature label="AI Messages" value="2,000/month" />
<PlanFeature label="Templates" value="All" />
<PlanFeature label="Support" value="Priority" />
</>
) : (
<>
<PlanFeature label="Projects" value="Unlimited" />
<PlanFeature label="AI Messages" value="10,000/month" />
<PlanFeature label="Team Members" value="Unlimited" />
<PlanFeature label="Support" value="Dedicated" />
</>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="notifications">
<Card>
<CardHeader>
<CardTitle>Notification Preferences</CardTitle>
<CardDescription>Choose how you want to be notified</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<NotificationToggle
label="Email Notifications"
description="Receive email updates about your projects"
checked={preferences.emailNotifications}
onCheckedChange={(checked) =>
setPreferences({ ...preferences, emailNotifications: checked })
}
/>
<Separator />
<NotificationToggle
label="Weekly Digest"
description="Get a weekly summary of your project activity"
checked={preferences.weeklyDigest}
onCheckedChange={(checked) =>
setPreferences({ ...preferences, weeklyDigest: checked })
}
/>
<Separator />
<NotificationToggle
label="Marketing Emails"
description="Receive updates about new features and offers"
checked={preferences.marketingEmails}
onCheckedChange={(checked) =>
setPreferences({ ...preferences, marketingEmails: checked })
}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security">
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>Manage your account security</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Password</p>
<p className="text-sm text-muted-foreground">
Last changed: Never
</p>
</div>
<Button variant="outline">Change Password</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Two-Factor Authentication</p>
<p className="text-sm text-muted-foreground">
Add an extra layer of security
</p>
</div>
<Badge variant="outline">Coming Soon</Badge>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Active Sessions</p>
<p className="text-sm text-muted-foreground">
1 active session
</p>
</div>
<Button variant="outline">View Sessions</Button>
</div>
<Separator />
<div className="pt-4">
<p className="font-medium text-destructive">Danger Zone</p>
<p className="text-sm text-muted-foreground mb-4">
Permanently delete your account and all data
</p>
<Button variant="destructive">Delete Account</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
function PlanFeature({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="font-medium">{value}</span>
</div>
)
}
function NotificationToggle({
label,
description,
checked,
onCheckedChange,
}: {
label: string
description: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
return (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{label}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
)
}

View File

@@ -0,0 +1,37 @@
import Link from 'next/link'
import { Button } from '@/components/ui/button'
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen flex flex-col">
<header className="border-b bg-white/80 backdrop-blur-sm dark:bg-zinc-950/80 sticky top-0 z-50">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<Link href="/" className="text-xl font-bold tracking-tight">
Mylder
</Link>
<div className="flex items-center gap-4">
<Link href="/pricing">
<Button variant="ghost" size="sm">Pricing</Button>
</Link>
<Link href="/login">
<Button variant="ghost" size="sm">Log in</Button>
</Link>
<Link href="/signup">
<Button size="sm">Get Started</Button>
</Link>
</div>
</nav>
</header>
<main className="flex-1">
{children}
</main>
<footer className="border-t py-8 text-center text-sm text-muted-foreground">
<p>&copy; {new Date().getFullYear()} Mylder. All rights reserved.</p>
</footer>
</div>
)
}

View File

@@ -0,0 +1,119 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Check, Loader2 } from 'lucide-react'
import { PLANS, type PlanKey } from '@/lib/stripe/config'
export default function PricingPage() {
const router = useRouter()
const [loading, setLoading] = useState<PlanKey | null>(null)
const handleSubscribe = async (plan: PlanKey) => {
if (plan === 'free') {
router.push('/auth/signup')
return
}
setLoading(plan)
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan }),
})
const data = await res.json()
if (data.error === 'Unauthorized') {
router.push('/auth/login?redirect=/pricing')
return
}
if (data.url) {
window.location.href = data.url
}
} catch (error) {
console.error('Checkout error:', error)
} finally {
setLoading(null)
}
}
return (
<div className="min-h-screen bg-gradient-to-b from-zinc-50 to-white dark:from-zinc-950 dark:to-zinc-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
<div className="text-center mb-16">
<h1 className="text-4xl font-bold tracking-tight mb-4">
Simple, transparent pricing
</h1>
<p className="text-xl text-zinc-600 dark:text-zinc-400 max-w-2xl mx-auto">
Start free, upgrade when you need more. No hidden fees.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
{(Object.entries(PLANS) as [PlanKey, typeof PLANS[PlanKey]][]).map(([key, plan]) => (
<Card
key={key}
className={`relative ${
key === 'pro' ? 'border-brand shadow-lg scale-105' : ''
}`}
>
{key === 'pro' && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2" variant="brand">
Most Popular
</Badge>
)}
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>
<span className="text-4xl font-bold text-foreground">
${plan.price}
</span>
{plan.price > 0 && (
<span className="text-muted-foreground">/month</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2">
<Check className="h-5 w-5 text-brand shrink-0 mt-0.5" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
variant={key === 'pro' ? 'brand' : 'outline'}
onClick={() => handleSubscribe(key)}
disabled={loading !== null}
>
{loading === key ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : key === 'free' ? (
'Get Started'
) : (
'Subscribe'
)}
</Button>
</CardFooter>
</Card>
))}
</div>
<div className="mt-16 text-center text-sm text-muted-foreground">
<p>All plans include SSL encryption and 99.9% uptime SLA.</p>
<p className="mt-2">Questions? Contact support@mylder.io</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { stripe, PLANS, type PlanKey } from '@/lib/stripe/config'
import type { Database } from '@/types/database'
type Subscription = Database['public']['Tables']['subscriptions']['Row']
export async function POST(request: Request) {
try {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { plan } = await request.json() as { plan: PlanKey }
const planConfig = PLANS[plan]
if (!planConfig || !planConfig.priceId) {
return NextResponse.json({ error: 'Invalid plan' }, { status: 400 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: subscription } = await (supabase as any)
.from('subscriptions')
.select('stripe_customer_id')
.eq('user_id', user.id)
.single() as { data: Pick<Subscription, 'stripe_customer_id'> | null }
let customerId = subscription?.stripe_customer_id
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
})
customerId = customer.id
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (supabase as any)
.from('subscriptions')
.upsert({
user_id: user.id,
stripe_customer_id: customerId,
plan: 'free',
status: 'active',
})
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: planConfig.priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing?checkout=cancelled`,
metadata: { userId: user.id, plan },
})
return NextResponse.json({ url: session.url })
} catch (error) {
console.error('Checkout error:', error)
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 })
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { stripe } from '@/lib/stripe/config'
import type { Database } from '@/types/database'
type Subscription = Database['public']['Tables']['subscriptions']['Row']
export async function POST() {
try {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: subscription } = await (supabase as any)
.from('subscriptions')
.select('stripe_customer_id')
.eq('user_id', user.id)
.single() as { data: Pick<Subscription, 'stripe_customer_id'> | null }
if (!subscription?.stripe_customer_id) {
return NextResponse.json({ error: 'No subscription found' }, { status: 404 })
}
const session = await stripe.billingPortal.sessions.create({
customer: subscription.stripe_customer_id,
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/settings`,
})
return NextResponse.json({ url: session.url })
} catch (error) {
console.error('Portal error:', error)
return NextResponse.json({ error: 'Failed to create portal session' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/config'
import { createClient } from '@supabase/supabase-js'
import type Stripe from 'stripe'
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
export async function POST(request: Request) {
const body = await request.text()
const headersList = await headers()
const signature = headersList.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.metadata?.userId
const plan = session.metadata?.plan
if (userId && session.subscription) {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
)
await supabaseAdmin
.from('subscriptions')
.upsert({
user_id: userId,
stripe_customer_id: session.customer as string,
stripe_subscription_id: subscription.id,
plan: plan || 'pro',
status: subscription.status,
current_period_start: new Date((subscription as unknown as { current_period_start: number }).current_period_start * 1000).toISOString(),
current_period_end: new Date((subscription as unknown as { current_period_end: number }).current_period_end * 1000).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
})
}
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const customerId = subscription.customer as string
const { data: existingSub } = await supabaseAdmin
.from('subscriptions')
.select('user_id')
.eq('stripe_customer_id', customerId)
.single()
if (existingSub) {
const sub = subscription as unknown as { current_period_start: number; current_period_end: number }
await supabaseAdmin
.from('subscriptions')
.update({
status: subscription.status,
plan: subscription.status === 'canceled' ? 'free' : undefined,
current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
cancel_at_period_end: subscription.cancel_at_period_end,
updated_at: new Date().toISOString(),
})
.eq('stripe_customer_id', customerId)
}
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
const customerId = invoice.customer as string
await supabaseAdmin
.from('subscriptions')
.update({ status: 'past_due', updated_at: new Date().toISOString() })
.eq('stripe_customer_id', customerId)
break
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook processing error:', error)
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 })
}
}

View File

@@ -3,120 +3,381 @@
@custom-variant dark (&:is(.dark *));
/* ============================================
MYLDER DESIGN SYSTEM
Warm neutrals + Amber accent
============================================ */
@theme inline {
/* Color mappings */
--color-background: var(--background);
--color-background-subtle: var(--background-subtle);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
/* Brand colors */
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-brand-muted: var(--brand-muted);
/* Semantic colors */
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
/* UI colors */
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Chart colors */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Sidebar colors */
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* Typography */
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
/* Border radius */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 16px);
}
/* ============================================
LIGHT MODE (Default)
Warm stone neutrals + amber accent
============================================ */
:root {
/* Base radius */
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* Typography */
--font-sans: 'Plus Jakarta Sans', var(--font-geist-sans), system-ui, sans-serif;
--font-mono: 'JetBrains Mono', var(--font-geist-mono), monospace;
/* Brand - Amber */
--brand: oklch(0.75 0.16 75);
--brand-foreground: oklch(0.20 0.02 75);
--brand-muted: oklch(0.92 0.06 75);
/* Backgrounds - Warm white */
--background: oklch(0.99 0.002 75);
--background-subtle: oklch(0.975 0.004 75);
--foreground: oklch(0.15 0.01 75);
/* Cards & Surfaces */
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.15 0.01 75);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--popover-foreground: oklch(0.15 0.01 75);
/* Primary - Dark (for contrast) */
--primary: oklch(0.20 0.01 75);
--primary-foreground: oklch(0.98 0.002 75);
/* Secondary - Light gray */
--secondary: oklch(0.955 0.006 75);
--secondary-foreground: oklch(0.25 0.01 75);
/* Muted - Subtle */
--muted: oklch(0.955 0.006 75);
--muted-foreground: oklch(0.50 0.01 75);
/* Accent - Warm amber tint */
--accent: oklch(0.96 0.04 75);
--accent-foreground: oklch(0.25 0.02 75);
/* Semantic states */
--destructive: oklch(0.55 0.22 25);
--destructive-foreground: oklch(0.98 0 0);
--success: oklch(0.65 0.18 145);
--success-foreground: oklch(0.98 0 0);
--warning: oklch(0.80 0.15 85);
--warning-foreground: oklch(0.25 0.02 85);
--info: oklch(0.65 0.15 240);
--info-foreground: oklch(0.98 0 0);
/* Borders & Inputs */
--border: oklch(0.90 0.006 75);
--input: oklch(0.90 0.006 75);
--ring: oklch(0.75 0.16 75);
/* Charts - Vibrant palette */
--chart-1: oklch(0.75 0.16 75);
--chart-2: oklch(0.60 0.15 200);
--chart-3: oklch(0.65 0.18 280);
--chart-4: oklch(0.70 0.16 145);
--chart-5: oklch(0.65 0.20 25);
/* Sidebar */
--sidebar: oklch(0.985 0.003 75);
--sidebar-foreground: oklch(0.15 0.01 75);
--sidebar-primary: oklch(0.75 0.16 75);
--sidebar-primary-foreground: oklch(0.20 0.02 75);
--sidebar-accent: oklch(0.96 0.04 75);
--sidebar-accent-foreground: oklch(0.25 0.02 75);
--sidebar-border: oklch(0.90 0.006 75);
--sidebar-ring: oklch(0.75 0.16 75);
}
/* ============================================
DARK MODE
Warm dark + amber accent
============================================ */
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
/* Brand - Slightly brighter for dark bg */
--brand: oklch(0.78 0.15 75);
--brand-foreground: oklch(0.15 0.02 75);
--brand-muted: oklch(0.30 0.06 75);
/* Backgrounds - Warm dark */
--background: oklch(0.14 0.008 75);
--background-subtle: oklch(0.18 0.01 75);
--foreground: oklch(0.96 0.004 75);
/* Cards & Surfaces */
--card: oklch(0.20 0.01 75);
--card-foreground: oklch(0.96 0.004 75);
--popover: oklch(0.20 0.01 75);
--popover-foreground: oklch(0.96 0.004 75);
/* Primary - Light (for contrast) */
--primary: oklch(0.94 0.004 75);
--primary-foreground: oklch(0.18 0.01 75);
/* Secondary */
--secondary: oklch(0.26 0.012 75);
--secondary-foreground: oklch(0.94 0.004 75);
/* Muted */
--muted: oklch(0.26 0.012 75);
--muted-foreground: oklch(0.65 0.01 75);
/* Accent */
--accent: oklch(0.30 0.04 75);
--accent-foreground: oklch(0.94 0.004 75);
/* Semantic states - Adjusted for dark */
--destructive: oklch(0.65 0.20 22);
--destructive-foreground: oklch(0.98 0 0);
--success: oklch(0.70 0.16 145);
--success-foreground: oklch(0.15 0 0);
--warning: oklch(0.82 0.14 85);
--warning-foreground: oklch(0.20 0.02 85);
--info: oklch(0.70 0.14 240);
--info-foreground: oklch(0.15 0 0);
/* Borders & Inputs */
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--ring: oklch(0.78 0.15 75);
/* Charts - Adjusted for dark */
--chart-1: oklch(0.78 0.15 75);
--chart-2: oklch(0.68 0.14 200);
--chart-3: oklch(0.72 0.16 280);
--chart-4: oklch(0.75 0.15 145);
--chart-5: oklch(0.72 0.18 25);
/* Sidebar */
--sidebar: oklch(0.18 0.01 75);
--sidebar-foreground: oklch(0.96 0.004 75);
--sidebar-primary: oklch(0.78 0.15 75);
--sidebar-primary-foreground: oklch(0.15 0.02 75);
--sidebar-accent: oklch(0.26 0.03 75);
--sidebar-accent-foreground: oklch(0.94 0.004 75);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.78 0.15 75);
}
/* ============================================
BASE STYLES
============================================ */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground antialiased;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Focus visible styles */
:focus-visible {
@apply outline-2 outline-offset-2 outline-ring;
}
/* Selection color */
::selection {
background: oklch(0.75 0.16 75 / 0.3);
}
.dark ::selection {
background: oklch(0.78 0.15 75 / 0.4);
}
}
/* ============================================
UTILITY CLASSES
============================================ */
@layer utilities {
/* Brand text gradient */
.text-gradient-brand {
background: linear-gradient(90deg, oklch(0.70 0.18 75), oklch(0.65 0.16 85));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Brand background gradient */
.bg-gradient-brand {
background: linear-gradient(135deg, oklch(0.75 0.16 75), oklch(0.70 0.18 65));
}
/* Subtle brand tint */
.bg-brand-subtle {
background: linear-gradient(135deg,
oklch(0.75 0.16 75 / 0.05),
oklch(0.75 0.16 75 / 0.1));
}
/* Brand glow shadow */
.shadow-brand {
box-shadow:
0 0 0 1px oklch(0.75 0.16 75 / 0.1),
0 4px 16px oklch(0.75 0.16 75 / 0.15);
}
.shadow-brand-lg {
box-shadow:
0 0 0 1px oklch(0.75 0.16 75 / 0.15),
0 8px 32px oklch(0.75 0.16 75 / 0.2);
}
.dark .shadow-brand {
box-shadow:
0 0 0 1px oklch(0.78 0.15 75 / 0.2),
0 4px 16px oklch(0.78 0.15 75 / 0.2);
}
.dark .shadow-brand-lg {
box-shadow:
0 0 0 1px oklch(0.78 0.15 75 / 0.25),
0 8px 32px oklch(0.78 0.15 75 / 0.25);
}
/* Glass effect */
.glass {
background: oklch(1 0 0 / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.dark .glass {
background: oklch(0.14 0.008 75 / 0.8);
}
/* Shimmer loading animation */
.shimmer {
background: linear-gradient(
90deg,
oklch(0.95 0 0) 0%,
oklch(0.90 0 0) 50%,
oklch(0.95 0 0) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.dark .shimmer {
background: linear-gradient(
90deg,
oklch(0.25 0 0) 0%,
oklch(0.30 0 0) 50%,
oklch(0.25 0 0) 100%
);
background-size: 200% 100%;
}
}
/* ============================================
ANIMATIONS
============================================ */
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-brand {
0%, 100% {
box-shadow: 0 0 0 0 oklch(0.75 0.16 75 / 0.4);
}
50% {
box-shadow: 0 0 0 8px oklch(0.75 0.16 75 / 0);
}
}

5
src/app/health/route.ts Normal file
View File

@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok', timestamp: Date.now() })
}

View File

@@ -1,16 +1,18 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
const plusJakarta = Plus_Jakarta_Sans({
variable: "--font-geist-sans",
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700", "800"],
});
const geistMono = Geist_Mono({
const jetbrainsMono = JetBrains_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
@@ -26,7 +28,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
{children}
<Toaster />

View File

@@ -4,13 +4,13 @@ import { ArrowRight, Layers, MessageSquare, Zap, Clock, Shield, Users } from 'lu
export default function LandingPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-zinc-50 to-white dark:from-zinc-950 dark:to-zinc-900">
<div className="min-h-screen bg-gradient-to-b from-background to-background-subtle">
{/* Navigation */}
<nav className="fixed top-0 w-full z-50 border-b bg-white/80 backdrop-blur-sm dark:bg-zinc-950/80 dark:border-zinc-800">
<nav className="fixed top-0 w-full z-50 border-b glass">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center">
<div className="flex items-center gap-2">
<Layers className="h-8 w-8 text-primary" />
<Layers className="h-8 w-8 text-brand" />
<span className="text-xl font-bold">Mylder</span>
</div>
<div className="flex items-center gap-4">
@@ -18,7 +18,7 @@ export default function LandingPage() {
<Button variant="ghost">Log in</Button>
</Link>
<Link href="/signup">
<Button>Start a Project</Button>
<Button variant="brand">Start a Project</Button>
</Link>
</div>
</div>
@@ -28,21 +28,21 @@ export default function LandingPage() {
{/* Hero Section */}
<section className="pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium mb-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-brand/10 text-brand text-sm font-medium mb-8">
<Zap className="h-4 w-4" />
Professional Software Development
</div>
<h1 className="text-4xl sm:text-6xl font-bold tracking-tight text-zinc-900 dark:text-white mb-6">
<h1 className="text-4xl sm:text-6xl font-bold tracking-tight text-foreground mb-6">
Your vision,
<span className="text-primary"> professionally built</span>
<span className="text-gradient-brand"> professionally built</span>
</h1>
<p className="text-xl text-zinc-600 dark:text-zinc-400 mb-8 max-w-2xl mx-auto">
<p className="text-xl text-muted-foreground mb-8 max-w-2xl mx-auto">
A lean, professional process to bring your software ideas to life.
Chat with us, get real-time updates, and launch with confidence.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/signup">
<Button size="lg" className="gap-2">
<Button variant="brand" size="lg" className="gap-2">
Describe Your Project <ArrowRight className="h-4 w-4" />
</Button>
</Link>
@@ -56,13 +56,13 @@ export default function LandingPage() {
</section>
{/* How It Works */}
<section id="how-it-works" className="py-20 px-4 sm:px-6 lg:px-8 bg-zinc-50 dark:bg-zinc-900/50">
<section id="how-it-works" className="py-20 px-4 sm:px-6 lg:px-8 bg-muted/50">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white mb-4">
<h2 className="text-3xl font-bold text-foreground mb-4">
A streamlined process from idea to launch
</h2>
<p className="text-lg text-zinc-600 dark:text-zinc-400">
<p className="text-lg text-muted-foreground">
No complexity. No surprises. Just professional software development.
</p>
</div>
@@ -90,10 +90,10 @@ export default function LandingPage() {
<section id="features" className="py-20 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white mb-4">
<h2 className="text-3xl font-bold text-foreground mb-4">
Why clients choose Mylder
</h2>
<p className="text-lg text-zinc-600 dark:text-zinc-400">
<p className="text-lg text-muted-foreground">
A modern approach to software development that puts you in control.
</p>
</div>
@@ -133,47 +133,47 @@ export default function LandingPage() {
</section>
{/* Pricing Section */}
<section id="pricing" className="py-20 px-4 sm:px-6 lg:px-8 bg-zinc-50 dark:bg-zinc-900/50">
<section id="pricing" className="py-20 px-4 sm:px-6 lg:px-8 bg-muted/50">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white mb-4">
<h2 className="text-3xl font-bold text-foreground mb-4">
Transparent, project-based pricing
</h2>
<p className="text-lg text-zinc-600 dark:text-zinc-400">
<p className="text-lg text-muted-foreground">
Every project is unique. Get a custom quote based on your specific needs.
</p>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl border dark:border-zinc-800 p-8 md:p-12">
<div className="bg-card rounded-2xl border p-8 md:p-12 shadow-sm">
<div className="text-center">
<h3 className="text-2xl font-bold text-zinc-900 dark:text-white mb-4">
<h3 className="text-2xl font-bold text-foreground mb-4">
Start with a free consultation
</h3>
<p className="text-zinc-600 dark:text-zinc-400 mb-8 max-w-xl mx-auto">
<p className="text-muted-foreground mb-8 max-w-xl mx-auto">
Describe your project idea and we'll get back to you with a clear scope,
timeline, and quote. No obligations, no pressure.
</p>
<div className="grid sm:grid-cols-3 gap-6 mb-10 text-left">
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-primary mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-5 w-5 text-brand mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Fixed-price projects with clear deliverables</span>
<span className="text-sm text-muted-foreground">Fixed-price projects with clear deliverables</span>
</div>
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-primary mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-5 w-5 text-brand mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Milestone-based payments for larger projects</span>
<span className="text-sm text-muted-foreground">Milestone-based payments for larger projects</span>
</div>
<div className="flex items-start gap-3">
<svg className="h-5 w-5 text-primary mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="h-5 w-5 text-brand mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm text-zinc-600 dark:text-zinc-400">Retainer options for ongoing development</span>
<span className="text-sm text-muted-foreground">Retainer options for ongoing development</span>
</div>
</div>
<Link href="/signup">
<Button size="lg" className="gap-2">
<Button variant="brand" size="lg" className="gap-2">
Describe Your Project <ArrowRight className="h-4 w-4" />
</Button>
</Link>
@@ -183,7 +183,7 @@ export default function LandingPage() {
</section>
{/* CTA Section */}
<section className="py-20 px-4 sm:px-6 lg:px-8 bg-primary text-primary-foreground">
<section className="py-20 px-4 sm:px-6 lg:px-8 bg-gradient-brand text-brand-foreground">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl font-bold mb-4">
Ready to bring your idea to life?
@@ -200,13 +200,13 @@ export default function LandingPage() {
</section>
{/* Footer */}
<footer className="py-12 px-4 sm:px-6 lg:px-8 border-t dark:border-zinc-800">
<footer className="py-12 px-4 sm:px-6 lg:px-8 border-t">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-2">
<Layers className="h-6 w-6 text-primary" />
<Layers className="h-6 w-6 text-brand" />
<span className="font-semibold">Mylder</span>
</div>
<p className="text-sm text-zinc-500">
<p className="text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} Mylder. All rights reserved.
</p>
</div>
@@ -218,23 +218,23 @@ export default function LandingPage() {
function StepCard({ step, title, description }: { step: string; title: string; description: string }) {
return (
<div className="text-center">
<div className="w-12 h-12 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xl font-bold mx-auto mb-4">
<div className="w-12 h-12 rounded-full bg-brand text-brand-foreground flex items-center justify-center text-xl font-bold mx-auto mb-4">
{step}
</div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white mb-2">{title}</h3>
<p className="text-zinc-600 dark:text-zinc-400">{description}</p>
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
<p className="text-muted-foreground">{description}</p>
</div>
)
}
function FeatureCard({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
return (
<div className="p-6 rounded-xl border bg-white dark:bg-zinc-900 dark:border-zinc-800">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center text-primary mb-4">
<div className="p-6 rounded-xl border bg-card shadow-sm hover:shadow-md transition-shadow">
<div className="w-12 h-12 rounded-lg bg-brand/10 flex items-center justify-center text-brand mb-4">
{icon}
</div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-white mb-2">{title}</h3>
<p className="text-zinc-600 dark:text-zinc-400">{description}</p>
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
<p className="text-muted-foreground">{description}</p>
</div>
)
}

View File

@@ -0,0 +1,298 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { cn } from '@/lib/utils'
import { ProgressTimeline, PHASES, type Phase } from '@/components/ui/progress-timeline'
import { PhaseIndicator } from '@/components/ui/phase-indicator'
import { StatusBadge } from '@/components/ui/status-badge'
import { ActivityFeed, type Activity } from '@/components/ui/activity-feed'
import { MetricCard } from '@/components/ui/metric-card'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Brain,
Search,
Lightbulb,
Map,
Hammer,
Rocket,
BarChart3,
Sparkles,
ChevronRight,
Play,
Pause,
RotateCcw,
Zap,
Clock,
Target,
TrendingUp
} from 'lucide-react'
const phaseIcons: Record<Phase, React.ReactNode> = {
think: <Brain className="w-5 h-5" />,
evaluate: <Search className="w-5 h-5" />,
ideate: <Lightbulb className="w-5 h-5" />,
plan: <Map className="w-5 h-5" />,
create: <Hammer className="w-5 h-5" />,
deploy: <Rocket className="w-5 h-5" />,
analyze: <BarChart3 className="w-5 h-5" />,
enhance: <Sparkles className="w-5 h-5" />
}
const phaseDescriptions: Record<Phase, string> = {
think: 'Define the problem space and user needs',
evaluate: 'Assess technical feasibility and risks',
ideate: 'Generate solution options through divergent thinking',
plan: 'Select best approach and break into tasks',
create: 'Build the MVP with iterative development',
deploy: 'Ship to production with monitoring',
analyze: 'Collect metrics and user feedback',
enhance: 'Iterate based on data insights'
}
interface AgenticDashboardProps {
projectId: string
projectName: string
initialPhase?: Phase
initialCompleted?: Phase[]
className?: string
}
export function AgenticDashboard({
projectName,
initialPhase = 'think',
initialCompleted = [],
className
}: AgenticDashboardProps) {
const [currentPhase, setCurrentPhase] = useState<Phase>(initialPhase)
const [completedPhases, setCompletedPhases] = useState<Phase[]>(initialCompleted)
const [isRunning, setIsRunning] = useState(false)
const [selectedPhase, setSelectedPhase] = useState<Phase | null>(null)
const [activities] = useState<Activity[]>([
{ id: '1', type: 'success', message: 'Project initialized successfully', timestamp: new Date(Date.now() - 3600000) },
{ id: '2', type: 'phase_change', message: 'Started Think phase', timestamp: new Date(Date.now() - 1800000) },
{ id: '3', type: 'action', message: 'Analyzing user requirements...', timestamp: new Date(Date.now() - 900000) },
])
const progress = (completedPhases.length / PHASES.length) * 100
const currentIndex = PHASES.indexOf(currentPhase)
const handleAdvancePhase = () => {
if (currentIndex < PHASES.length - 1) {
setCompletedPhases([...completedPhases, currentPhase])
setCurrentPhase(PHASES[currentIndex + 1])
} else {
setCompletedPhases([...completedPhases, currentPhase])
setCurrentPhase('think')
}
}
const handleReset = () => {
setCurrentPhase('think')
setCompletedPhases([])
setIsRunning(false)
}
return (
<div className={cn('space-y-6', className)}>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{projectName}</h2>
<div className="flex items-center gap-2 mt-1">
<StatusBadge variant={isRunning ? 'info' : 'default'} pulse={isRunning}>
{isRunning ? 'Running' : 'Paused'}
</StatusBadge>
<span className="text-sm text-muted-foreground">
Iteration #{Math.floor(completedPhases.length / PHASES.length) + 1}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsRunning(!isRunning)}
>
{isRunning ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</Button>
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="w-4 h-4" />
</Button>
<Button onClick={handleAdvancePhase} className="gap-2">
Advance <ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* Progress Timeline */}
<Card>
<CardContent className="pt-6">
<ProgressTimeline
currentPhase={currentPhase}
completedPhases={completedPhases}
/>
<div className="flex items-center justify-center gap-2 mt-4 text-sm text-muted-foreground">
<span className="font-medium">{Math.round(progress)}%</span>
<span>complete</span>
</div>
</CardContent>
</Card>
{/* Phase Grid + Details */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Phase Cards */}
<div className="lg:col-span-2 grid sm:grid-cols-2 gap-3">
{PHASES.map((phase) => {
const isCompleted = completedPhases.includes(phase)
const isCurrent = phase === currentPhase
const status = isCompleted ? 'completed' : isCurrent ? 'in_progress' : 'not_started'
return (
<motion.div
key={phase}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setSelectedPhase(phase)}
>
<Card className={cn(
'cursor-pointer transition-all',
isCurrent && 'border-blue-500/50 bg-blue-500/5',
isCompleted && 'border-emerald-500/50 bg-emerald-500/5',
selectedPhase === phase && 'ring-2 ring-foreground/10'
)}>
<CardContent className="pt-4 pb-4">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
isCurrent && 'bg-blue-500/10 text-blue-500',
isCompleted && 'bg-emerald-500/10 text-emerald-500',
!isCurrent && !isCompleted && 'bg-muted text-muted-foreground'
)}>
{phaseIcons[phase]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium capitalize">{phase}</span>
<PhaseIndicator phase={phase} status={status} showLabel={false} size="sm" />
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{phaseDescriptions[phase]}
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
)
})}
</div>
{/* Activity Feed */}
<div>
<Card className="h-full">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="w-4 h-4" />
Activity
</CardTitle>
</CardHeader>
<CardContent>
<ActivityFeed activities={activities} maxItems={5} />
</CardContent>
</Card>
</div>
</div>
{/* Metrics */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Progress"
value={progress}
format="percent"
icon={<Target className="w-4 h-4" />}
/>
<MetricCard
label="Phases Complete"
value={completedPhases.length}
trend="up"
trendValue={`of ${PHASES.length}`}
icon={<TrendingUp className="w-4 h-4" />}
/>
<MetricCard
label="Time in Phase"
value={2.5}
format="duration"
icon={<Clock className="w-4 h-4" />}
/>
<MetricCard
label="Iterations"
value={Math.floor(completedPhases.length / PHASES.length) + 1}
icon={<RotateCcw className="w-4 h-4" />}
/>
</div>
{/* Phase Detail Panel */}
<AnimatePresence>
{selectedPhase && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-muted">
{phaseIcons[selectedPhase]}
</div>
<div>
<span className="capitalize">{selectedPhase}</span>
<p className="text-sm font-normal text-muted-foreground mt-0.5">
{phaseDescriptions[selectedPhase]}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-3 gap-4">
<div>
<h4 className="text-sm font-medium mb-2">Entry Criteria</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Previous phase completed</li>
<li> Required inputs available</li>
<li> Team capacity confirmed</li>
</ul>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Key Activities</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Research & discovery</li>
<li> Documentation</li>
<li> Stakeholder alignment</li>
</ul>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Exit Criteria</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Deliverables complete</li>
<li> Quality checks passed</li>
<li> Ready for next phase</li>
</ul>
</div>
</div>
<div className="flex justify-end mt-4">
<Button variant="ghost" size="sm" onClick={() => setSelectedPhase(null)}>
Close
</Button>
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -31,12 +31,12 @@ export function DashboardNav({ user }: { user: SupabaseUser }) {
.toUpperCase() || user.email?.[0].toUpperCase() || '?'
return (
<nav className="fixed top-0 w-full z-50 border-b bg-white/80 backdrop-blur-sm dark:bg-zinc-950/80 dark:border-zinc-800">
<nav className="fixed top-0 w-full z-50 border-b glass">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center">
<div className="flex items-center gap-8">
<Link href="/dashboard" className="flex items-center gap-2">
<Bot className="h-8 w-8 text-primary" />
<Bot className="h-8 w-8 text-brand" />
<span className="text-xl font-bold">Mylder</span>
</Link>
<div className="hidden md:flex items-center gap-4">
@@ -61,7 +61,7 @@ export function DashboardNav({ user }: { user: SupabaseUser }) {
{user.user_metadata?.full_name && (
<p className="font-medium">{user.user_metadata.full_name}</p>
)}
<p className="text-sm text-zinc-500 truncate">{user.email}</p>
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
</div>
</div>
<DropdownMenuSeparator />

View File

@@ -0,0 +1,80 @@
'use client'
import { motion, AnimatePresence } from 'motion/react'
import { formatDistanceToNow } from 'date-fns'
import { cn } from '@/lib/utils'
import {
CheckCircle2,
AlertCircle,
Info,
Zap,
ArrowRight,
Clock
} from 'lucide-react'
export interface Activity {
id: string
type: 'phase_change' | 'action' | 'notification' | 'error' | 'success'
message: string
timestamp: Date
metadata?: Record<string, unknown>
}
interface ActivityFeedProps {
activities: Activity[]
maxItems?: number
className?: string
}
const typeConfig = {
phase_change: { icon: ArrowRight, color: 'text-blue-500', bg: 'bg-blue-500/10' },
action: { icon: Zap, color: 'text-violet-500', bg: 'bg-violet-500/10' },
notification: { icon: Info, color: 'text-muted-foreground', bg: 'bg-muted' },
error: { icon: AlertCircle, color: 'text-red-500', bg: 'bg-red-500/10' },
success: { icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' },
}
export function ActivityFeed({ activities, maxItems = 10, className }: ActivityFeedProps) {
const displayActivities = activities.slice(0, maxItems)
if (displayActivities.length === 0) {
return (
<div className={cn('flex flex-col items-center justify-center py-8 text-muted-foreground', className)}>
<Clock className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">No activity yet</p>
</div>
)
}
return (
<div className={cn('space-y-2', className)}>
<AnimatePresence mode="popLayout">
{displayActivities.map((activity, index) => {
const config = typeConfig[activity.type]
const Icon = config.icon
return (
<motion.div
key={activity.id}
initial={{ opacity: 0, x: -20, height: 0 }}
animate={{ opacity: 1, x: 0, height: 'auto' }}
exit={{ opacity: 0, x: 20, height: 0 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
className="flex items-start gap-3 p-3 rounded-lg bg-card border hover:border-foreground/10 transition-colors"
>
<div className={cn('p-1.5 rounded-full shrink-0', config.bg)}>
<Icon className={cn('w-3.5 h-3.5', config.color)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground">{activity.message}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
</p>
</div>
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}

View File

@@ -11,10 +11,26 @@ const badgeVariants = cva(
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
brand:
"border-transparent bg-brand text-brand-foreground [a&]:hover:bg-brand/90",
"brand-outline":
"border-brand/30 bg-brand/10 text-brand [a&]:hover:bg-brand/20",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
success:
"border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90",
"success-outline":
"border-success/30 bg-success/10 text-success [a&]:hover:bg-success/20",
warning:
"border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90",
"warning-outline":
"border-warning/30 bg-warning/10 text-warning [a&]:hover:bg-warning/20",
info:
"border-transparent bg-info text-info-foreground [a&]:hover:bg-info/90",
"info-outline":
"border-info/30 bg-info/10 text-info [a&]:hover:bg-info/20",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},

View File

@@ -10,15 +10,24 @@ const buttonVariants = cva(
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
brand:
"bg-brand text-brand-foreground hover:bg-brand/90 shadow-brand hover:shadow-brand-lg transition-shadow",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
success:
"bg-success text-success-foreground hover:bg-success/90",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"outline-brand":
"border-brand/30 bg-brand/5 text-brand hover:bg-brand/10 hover:border-brand/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"ghost-brand":
"text-brand hover:bg-brand/10 hover:text-brand",
link: "text-primary underline-offset-4 hover:underline",
"link-brand": "text-brand underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@@ -0,0 +1,76 @@
'use client'
import { motion, useSpring, useTransform } from 'motion/react'
import { useEffect } from 'react'
import { cn } from '@/lib/utils'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
interface MetricCardProps {
label: string
value: number
format?: 'number' | 'percent' | 'duration'
trend?: 'up' | 'down' | 'neutral'
trendValue?: string
icon?: React.ReactNode
className?: string
}
function AnimatedNumber({ value, format = 'number' }: { value: number; format?: string }) {
const spring = useSpring(0, { stiffness: 100, damping: 30 })
const display = useTransform(spring, (current) => {
if (format === 'percent') return `${Math.round(current)}%`
if (format === 'duration') return `${current.toFixed(1)}s`
return Math.round(current).toLocaleString()
})
useEffect(() => {
spring.set(value)
}, [spring, value])
return <motion.span>{display}</motion.span>
}
export function MetricCard({
label,
value,
format = 'number',
trend,
trendValue,
icon,
className
}: MetricCardProps) {
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : Minus
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -2, transition: { duration: 0.2 } }}
className={cn(
'p-4 rounded-xl border bg-card hover:border-foreground/10 transition-colors',
className
)}
>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">{label}</span>
{icon && <span className="text-muted-foreground">{icon}</span>}
</div>
<div className="flex items-end gap-2">
<span className="text-2xl font-bold">
<AnimatedNumber value={value} format={format} />
</span>
{trend && trendValue && (
<span className={cn(
'flex items-center gap-0.5 text-xs font-medium pb-1',
trend === 'up' && 'text-emerald-500',
trend === 'down' && 'text-red-500',
trend === 'neutral' && 'text-muted-foreground'
)}>
<TrendIcon className="w-3 h-3" />
{trendValue}
</span>
)}
</div>
</motion.div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Check, Circle, Loader2, AlertTriangle, X, SkipForward } from 'lucide-react'
export type PhaseStatus = 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'failed' | 'skipped'
interface PhaseIndicatorProps {
phase: string
status: PhaseStatus
label?: string
size?: 'sm' | 'md' | 'lg'
showLabel?: boolean
}
const statusConfig: Record<PhaseStatus, { icon: typeof Circle; color: string; bg: string; ring: string; animate?: boolean }> = {
not_started: { icon: Circle, color: 'text-muted-foreground', bg: 'bg-muted', ring: '' },
in_progress: { icon: Loader2, color: 'text-blue-500', bg: 'bg-blue-500/10', ring: 'ring-2 ring-blue-500/30', animate: true },
completed: { icon: Check, color: 'text-emerald-500', bg: 'bg-emerald-500/10', ring: '' },
blocked: { icon: AlertTriangle, color: 'text-amber-500', bg: 'bg-amber-500/10', ring: 'ring-2 ring-amber-500/30' },
failed: { icon: X, color: 'text-red-500', bg: 'bg-red-500/10', ring: '' },
skipped: { icon: SkipForward, color: 'text-muted-foreground/50', bg: 'bg-muted/50', ring: '' },
}
const sizeConfig = {
sm: { wrapper: 'p-1.5', icon: 'w-3 h-3', text: 'text-xs' },
md: { wrapper: 'p-2', icon: 'w-4 h-4', text: 'text-sm' },
lg: { wrapper: 'p-3', icon: 'w-5 h-5', text: 'text-base' },
}
export function PhaseIndicator({ phase, status, label, size = 'md', showLabel = true }: PhaseIndicatorProps) {
const config = statusConfig[status]
const sizes = sizeConfig[size]
const Icon = config.icon
return (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="flex items-center gap-2"
>
<motion.div
className={cn('rounded-full transition-all duration-300', sizes.wrapper, config.bg, config.ring)}
animate={status === 'in_progress' ? { scale: [1, 1.05, 1] } : {}}
transition={{ repeat: Infinity, duration: 2 }}
>
<Icon className={cn(sizes.icon, config.color, config.animate && 'animate-spin')} />
</motion.div>
{showLabel && (
<span className={cn('font-medium capitalize', sizes.text, status === 'in_progress' ? 'text-foreground' : 'text-muted-foreground')}>
{label || phase}
</span>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,141 @@
'use client'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
import { Check, Circle, Loader2 } from 'lucide-react'
export const PHASES = ['think', 'evaluate', 'ideate', 'plan', 'create', 'deploy', 'analyze', 'enhance'] as const
export type Phase = typeof PHASES[number]
interface ProgressTimelineProps {
currentPhase: Phase
completedPhases: Phase[]
className?: string
variant?: 'horizontal' | 'vertical'
showLabels?: boolean
}
export function ProgressTimeline({
currentPhase,
completedPhases,
className,
variant = 'horizontal',
showLabels = true
}: ProgressTimelineProps) {
const progress = (completedPhases.length / PHASES.length) * 100
if (variant === 'vertical') {
return (
<div className={cn('flex flex-col gap-1', className)}>
{PHASES.map((phase, index) => {
const isCompleted = completedPhases.includes(phase)
const isCurrent = phase === currentPhase
const isPast = PHASES.indexOf(phase) < PHASES.indexOf(currentPhase)
return (
<motion.div
key={phase}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="flex items-center gap-3"
>
<div className="flex flex-col items-center">
<motion.div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center transition-all duration-300',
isCompleted && 'bg-emerald-500 text-white',
isCurrent && 'bg-blue-500 text-white ring-4 ring-blue-500/20',
!isCompleted && !isCurrent && 'bg-muted text-muted-foreground'
)}
animate={isCurrent ? { scale: [1, 1.1, 1] } : {}}
transition={{ repeat: Infinity, duration: 2 }}
>
{isCompleted ? (
<Check className="w-4 h-4" />
) : isCurrent ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Circle className="w-4 h-4" />
)}
</motion.div>
{index < PHASES.length - 1 && (
<div className={cn(
'w-0.5 h-6 transition-colors duration-300',
isPast || isCompleted ? 'bg-emerald-500' : 'bg-muted'
)} />
)}
</div>
{showLabels && (
<span className={cn(
'text-sm capitalize transition-colors',
isCurrent && 'font-semibold text-foreground',
isCompleted && 'text-emerald-600',
!isCurrent && !isCompleted && 'text-muted-foreground'
)}>
{phase}
</span>
)}
</motion.div>
)
})}
</div>
)
}
return (
<div className={cn('w-full', className)}>
<div className="relative h-2 bg-muted rounded-full overflow-hidden mb-4">
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 via-violet-500 to-emerald-500"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
<motion.div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500/50 via-violet-500/50 to-emerald-500/50 blur-sm"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
<div className="flex justify-between">
{PHASES.map((phase, index) => {
const isCompleted = completedPhases.includes(phase)
const isCurrent = phase === currentPhase
return (
<motion.div
key={phase}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="flex flex-col items-center"
>
<motion.div
className={cn(
'w-3 h-3 rounded-full transition-all duration-300 mb-1',
isCompleted && 'bg-emerald-500',
isCurrent && 'bg-blue-500 ring-4 ring-blue-500/20',
!isCompleted && !isCurrent && 'bg-muted-foreground/30'
)}
animate={isCurrent ? { scale: [1, 1.3, 1] } : {}}
transition={{ repeat: Infinity, duration: 1.5 }}
/>
{showLabels && (
<span className={cn(
'text-xs capitalize hidden sm:block',
isCurrent && 'font-semibold text-foreground',
!isCurrent && 'text-muted-foreground'
)}>
{phase}
</span>
)}
</motion.div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { cn } from '@/lib/utils'
const variants = {
default: 'bg-muted text-muted-foreground border-muted',
success: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
warning: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
error: 'bg-red-500/10 text-red-600 border-red-500/20',
info: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
purple: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
}
interface StatusBadgeProps {
variant?: keyof typeof variants
children: React.ReactNode
pulse?: boolean
className?: string
}
export function StatusBadge({ variant = 'default', children, pulse, className }: StatusBadgeProps) {
const pulseColor = {
default: 'bg-muted-foreground',
success: 'bg-emerald-500',
warning: 'bg-amber-500',
error: 'bg-red-500',
info: 'bg-blue-500',
purple: 'bg-violet-500',
}
return (
<span className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border',
variants[variant],
className
)}>
{pulse && (
<span className="relative flex h-2 w-2">
<span className={cn(
'animate-ping absolute inline-flex h-full w-full rounded-full opacity-75',
pulseColor[variant]
)} />
<span className={cn(
'relative inline-flex rounded-full h-2 w-2',
pulseColor[variant]
)} />
</span>
)}
{children}
</span>
)
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,187 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { Phase } from '@/components/ui/progress-timeline'
import type { Activity } from '@/components/ui/activity-feed'
export interface ProjectPhaseState {
projectId: string
currentPhase: Phase
completedPhases: Phase[]
phaseStatus: 'not_started' | 'in_progress' | 'completed' | 'blocked' | 'failed'
activities: Activity[]
metrics: {
totalDuration: number
phaseDurations: Record<Phase, number>
iterationCount: number
}
}
export function useProjectPhase(projectId: string) {
const [state, setState] = useState<ProjectPhaseState | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchProjectState = useCallback(async () => {
const supabase = createClient()
try {
const { data: project, error: projectError } = await supabase
.from('projects')
.select('*')
.eq('id', projectId)
.single()
if (projectError) throw projectError
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metadata = (project as any)?.phase_metadata as ProjectPhaseState | undefined
setState({
projectId,
currentPhase: metadata?.currentPhase || 'think',
completedPhases: metadata?.completedPhases || [],
phaseStatus: metadata?.phaseStatus || 'not_started',
activities: metadata?.activities || [],
metrics: metadata?.metrics || {
totalDuration: 0,
phaseDurations: {} as Record<Phase, number>,
iterationCount: 0
}
})
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch project'))
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => {
fetchProjectState()
const supabase = createClient()
const channel = supabase
.channel(`project:${projectId}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'projects',
filter: `id=eq.${projectId}`
}, (payload) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metadata = (payload.new as any)?.phase_metadata as ProjectPhaseState | undefined
if (metadata) {
setState(prev => prev ? { ...prev, ...metadata, projectId } : null)
}
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [projectId, fetchProjectState])
const transitionPhase = useCallback(async (newPhase: Phase) => {
if (!state) return
const supabase = createClient()
const newActivity: Activity = {
id: crypto.randomUUID(),
type: 'phase_change',
message: `Transitioned to ${newPhase} phase`,
timestamp: new Date()
}
const updatedState: ProjectPhaseState = {
...state,
currentPhase: newPhase,
completedPhases: state.currentPhase !== newPhase
? [...state.completedPhases, state.currentPhase]
: state.completedPhases,
phaseStatus: 'in_progress',
activities: [newActivity, ...state.activities].slice(0, 50)
}
// Optimistic update
setState(updatedState)
// Persist to database
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: updateError } = await (supabase as any)
.from('projects')
.update({ phase_metadata: updatedState })
.eq('id', projectId)
if (updateError) {
console.error('Failed to persist phase:', updateError)
// Revert on error
setState(state)
}
}, [state, projectId])
const setPhaseStatus = useCallback(async (status: ProjectPhaseState['phaseStatus']) => {
if (!state) return
const supabase = createClient()
const updatedState: ProjectPhaseState = {
...state,
phaseStatus: status
}
setState(updatedState)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: updateError } = await (supabase as any)
.from('projects')
.update({ phase_metadata: updatedState })
.eq('id', projectId)
if (updateError) {
console.error('Failed to update phase status:', updateError)
setState(state)
}
}, [state, projectId])
const addActivity = useCallback(async (activity: Omit<Activity, 'id' | 'timestamp'>) => {
if (!state) return
const supabase = createClient()
const newActivity: Activity = {
...activity,
id: crypto.randomUUID(),
timestamp: new Date()
}
const updatedState: ProjectPhaseState = {
...state,
activities: [newActivity, ...state.activities].slice(0, 50)
}
setState(updatedState)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: updateError } = await (supabase as any)
.from('projects')
.update({ phase_metadata: updatedState })
.eq('id', projectId)
if (updateError) {
console.error('Failed to add activity:', updateError)
setState(state)
}
}, [state, projectId])
return {
state,
loading,
error,
transitionPhase,
setPhaseStatus,
addActivity,
refresh: fetchProjectState
}
}

12
src/lib/stripe/client.ts Normal file
View File

@@ -0,0 +1,12 @@
'use client'
import { loadStripe, type Stripe } from '@stripe/stripe-js'
let stripePromise: Promise<Stripe | null> | null = null
export function getStripe() {
if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
}
return stripePromise
}

57
src/lib/stripe/config.ts Normal file
View File

@@ -0,0 +1,57 @@
import Stripe from 'stripe'
const stripeSecretKey = process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder'
export const stripe = new Stripe(stripeSecretKey)
export const PLANS = {
free: {
name: 'Free',
price: 0,
priceId: null,
features: [
'1 project',
'50 AI messages/month',
'Basic templates',
'Community support',
],
limits: {
projects: 1,
messages: 50,
},
},
pro: {
name: 'Pro',
price: 19,
priceId: process.env.STRIPE_PRO_PRICE_ID,
features: [
'Unlimited projects',
'2,000 AI messages/month',
'All templates',
'Priority support',
'API access',
],
limits: {
projects: -1,
messages: 2000,
},
},
team: {
name: 'Team',
price: 49,
priceId: process.env.STRIPE_TEAM_PRICE_ID,
features: [
'Everything in Pro',
'10,000 AI messages/month',
'Team collaboration',
'Admin dashboard',
'Custom integrations',
],
limits: {
projects: -1,
messages: 10000,
},
},
} as const
export type PlanKey = keyof typeof PLANS

546
src/styles/design-system.md Normal file
View File

@@ -0,0 +1,546 @@
# Mylder Brand Design System
## Brand Identity
**Tagline**: Intelligent Automation, Human Touch
**Personality**: Professional, Approachable, Innovative, Trustworthy
**Aesthetic**: Refined Tech-Forward Minimalism with Warm Energy
---
## Color System (OKLCH)
### Philosophy
Using OKLCH color space for perceptually uniform colors. Warm stone neutrals replace cold grays to feel more human/approachable for an AI platform. Amber accent represents energy and intelligence.
### Primary Palette
#### Brand Colors
```css
/* Amber - Primary Brand Accent */
--brand: oklch(0.75 0.16 75); /* Vibrant amber */
--brand-hover: oklch(0.70 0.18 75); /* Darker on hover */
--brand-muted: oklch(0.85 0.08 75); /* Soft amber for backgrounds */
--brand-foreground: oklch(0.20 0.02 75); /* Dark text on brand */
/* Stone - Warm Neutrals (replaces zinc/gray) */
--stone-50: oklch(0.985 0.002 75); /* Near white, warm */
--stone-100: oklch(0.965 0.004 75); /* Subtle warm */
--stone-200: oklch(0.925 0.006 75); /* Light warm */
--stone-300: oklch(0.87 0.008 75); /* Medium-light */
--stone-400: oklch(0.70 0.01 75); /* Medium */
--stone-500: oklch(0.55 0.012 75); /* Medium-dark */
--stone-600: oklch(0.45 0.014 75); /* Dark */
--stone-700: oklch(0.35 0.012 75); /* Darker */
--stone-800: oklch(0.25 0.01 75); /* Very dark */
--stone-900: oklch(0.18 0.008 75); /* Near black, warm */
--stone-950: oklch(0.12 0.006 75); /* Deepest */
```
### Semantic Colors
#### Light Mode
```css
:root {
/* Backgrounds */
--background: oklch(0.99 0.002 75); /* Warm white */
--background-subtle: oklch(0.975 0.004 75); /* Subtle warmth */
--foreground: oklch(0.15 0.01 75); /* Warm black */
/* Cards & Surfaces */
--card: oklch(1 0 0); /* Pure white cards */
--card-foreground: oklch(0.15 0.01 75);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.15 0.01 75);
/* Primary (Dark buttons/CTAs) */
--primary: oklch(0.20 0.01 75);
--primary-foreground: oklch(0.98 0.002 75);
/* Secondary (Light buttons) */
--secondary: oklch(0.955 0.006 75);
--secondary-foreground: oklch(0.25 0.01 75);
/* Muted (Disabled, hints) */
--muted: oklch(0.955 0.006 75);
--muted-foreground: oklch(0.50 0.01 75);
/* Accent (Amber brand) */
--accent: oklch(0.92 0.06 75);
--accent-foreground: oklch(0.25 0.02 75);
/* Brand (Primary amber) */
--brand: oklch(0.75 0.16 75);
--brand-foreground: oklch(0.20 0.02 75);
/* Destructive */
--destructive: oklch(0.55 0.22 25);
--destructive-foreground: oklch(0.98 0 0);
/* Success */
--success: oklch(0.65 0.18 145);
--success-foreground: oklch(0.98 0 0);
/* Warning */
--warning: oklch(0.80 0.15 85);
--warning-foreground: oklch(0.25 0.02 85);
/* Info */
--info: oklch(0.65 0.15 240);
--info-foreground: oklch(0.98 0 0);
/* Borders & Inputs */
--border: oklch(0.90 0.006 75);
--input: oklch(0.90 0.006 75);
--ring: oklch(0.75 0.16 75); /* Amber focus ring */
}
```
#### Dark Mode
```css
.dark {
/* Backgrounds */
--background: oklch(0.14 0.008 75); /* Warm dark */
--background-subtle: oklch(0.18 0.01 75);
--foreground: oklch(0.96 0.004 75); /* Warm white */
/* Cards & Surfaces */
--card: oklch(0.20 0.01 75);
--card-foreground: oklch(0.96 0.004 75);
--popover: oklch(0.20 0.01 75);
--popover-foreground: oklch(0.96 0.004 75);
/* Primary (Light buttons on dark) */
--primary: oklch(0.94 0.004 75);
--primary-foreground: oklch(0.18 0.01 75);
/* Secondary */
--secondary: oklch(0.26 0.012 75);
--secondary-foreground: oklch(0.94 0.004 75);
/* Muted */
--muted: oklch(0.26 0.012 75);
--muted-foreground: oklch(0.65 0.01 75);
/* Accent (Amber, slightly brighter for dark) */
--accent: oklch(0.30 0.04 75);
--accent-foreground: oklch(0.94 0.004 75);
/* Brand (Amber, adjusted for dark) */
--brand: oklch(0.78 0.15 75);
--brand-foreground: oklch(0.15 0.02 75);
/* Destructive */
--destructive: oklch(0.65 0.20 22);
--destructive-foreground: oklch(0.98 0 0);
/* Success */
--success: oklch(0.70 0.16 145);
--success-foreground: oklch(0.15 0 0);
/* Warning */
--warning: oklch(0.82 0.14 85);
--warning-foreground: oklch(0.20 0.02 85);
/* Info */
--info: oklch(0.70 0.14 240);
--info-foreground: oklch(0.15 0 0);
/* Borders & Inputs */
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.78 0.15 75);
}
```
### Chart Colors (Data Visualization)
```css
/* Light mode - vibrant, distinguishable */
--chart-1: oklch(0.75 0.16 75); /* Amber (brand) */
--chart-2: oklch(0.60 0.15 200); /* Teal */
--chart-3: oklch(0.65 0.18 280); /* Purple */
--chart-4: oklch(0.70 0.16 145); /* Green */
--chart-5: oklch(0.65 0.20 25); /* Coral */
/* Dark mode - adjusted for contrast */
--chart-1: oklch(0.78 0.15 75);
--chart-2: oklch(0.68 0.14 200);
--chart-3: oklch(0.72 0.16 280);
--chart-4: oklch(0.75 0.15 145);
--chart-5: oklch(0.72 0.18 25);
```
---
## Typography
### Font Stack
```css
/* Primary - Display & Headings */
--font-display: 'Plus Jakarta Sans', system-ui, sans-serif;
/* Body - Reading & UI */
--font-body: 'Plus Jakarta Sans', system-ui, sans-serif;
/* Mono - Code & Technical */
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
```
### Type Scale (Fluid)
```css
/* Using clamp() for fluid typography */
--text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.8rem); /* 12-13px */
--text-sm: clamp(0.8125rem, 0.77rem + 0.21vw, 0.875rem); /* 13-14px */
--text-base: clamp(0.9375rem, 0.9rem + 0.19vw, 1rem); /* 15-16px */
--text-lg: clamp(1.0625rem, 1rem + 0.31vw, 1.125rem); /* 17-18px */
--text-xl: clamp(1.1875rem, 1.1rem + 0.44vw, 1.25rem); /* 19-20px */
--text-2xl: clamp(1.375rem, 1.25rem + 0.63vw, 1.5rem); /* 22-24px */
--text-3xl: clamp(1.625rem, 1.45rem + 0.88vw, 1.875rem); /* 26-30px */
--text-4xl: clamp(2rem, 1.75rem + 1.25vw, 2.25rem); /* 32-36px */
--text-5xl: clamp(2.5rem, 2.1rem + 2vw, 3rem); /* 40-48px */
--text-6xl: clamp(3rem, 2.5rem + 2.5vw, 3.75rem); /* 48-60px */
```
### Line Heights
```css
--leading-none: 1;
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-loose: 2;
```
### Font Weights
```css
--font-light: 300;
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--font-extrabold: 800;
```
### Letter Spacing
```css
--tracking-tighter: -0.05em;
--tracking-tight: -0.025em;
--tracking-normal: 0;
--tracking-wide: 0.025em;
--tracking-wider: 0.05em;
--tracking-widest: 0.1em;
```
---
## Spacing & Layout
### Spacing Scale
```css
/* Base unit: 4px */
--space-0: 0;
--space-px: 1px;
--space-0.5: 0.125rem; /* 2px */
--space-1: 0.25rem; /* 4px */
--space-1.5: 0.375rem; /* 6px */
--space-2: 0.5rem; /* 8px */
--space-2.5: 0.625rem; /* 10px */
--space-3: 0.75rem; /* 12px */
--space-3.5: 0.875rem; /* 14px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-7: 1.75rem; /* 28px */
--space-8: 2rem; /* 32px */
--space-9: 2.25rem; /* 36px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-14: 3.5rem; /* 56px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
--space-24: 6rem; /* 96px */
--space-28: 7rem; /* 112px */
--space-32: 8rem; /* 128px */
```
### Container Widths
```css
--container-xs: 20rem; /* 320px */
--container-sm: 24rem; /* 384px */
--container-md: 28rem; /* 448px */
--container-lg: 32rem; /* 512px */
--container-xl: 36rem; /* 576px */
--container-2xl: 42rem; /* 672px */
--container-3xl: 48rem; /* 768px */
--container-4xl: 56rem; /* 896px */
--container-5xl: 64rem; /* 1024px */
--container-6xl: 72rem; /* 1152px */
--container-7xl: 80rem; /* 1280px */
--container-full: 100%;
```
### Breakpoints
```css
--screen-sm: 640px;
--screen-md: 768px;
--screen-lg: 1024px;
--screen-xl: 1280px;
--screen-2xl: 1536px;
```
---
## Border Radius
```css
--radius-none: 0;
--radius-sm: 0.25rem; /* 4px - subtle */
--radius-md: 0.5rem; /* 8px - default */
--radius-lg: 0.75rem; /* 12px - cards */
--radius-xl: 1rem; /* 16px - modals */
--radius-2xl: 1.5rem; /* 24px - large surfaces */
--radius-3xl: 2rem; /* 32px - hero elements */
--radius-full: 9999px; /* Pills, avatars */
```
---
## Shadows
### Light Mode
```css
--shadow-xs: 0 1px 2px oklch(0 0 0 / 0.04);
--shadow-sm: 0 1px 3px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.04);
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.06), 0 2px 4px -1px oklch(0 0 0 / 0.04);
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.06), 0 4px 6px -2px oklch(0 0 0 / 0.03);
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.08), 0 10px 10px -5px oklch(0 0 0 / 0.03);
--shadow-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.15);
/* Brand glow (for CTAs) */
--shadow-brand: 0 0 0 1px oklch(0.75 0.16 75 / 0.1),
0 4px 16px oklch(0.75 0.16 75 / 0.15);
--shadow-brand-lg: 0 0 0 1px oklch(0.75 0.16 75 / 0.15),
0 8px 32px oklch(0.75 0.16 75 / 0.2);
```
### Dark Mode
```css
--shadow-xs: 0 1px 2px oklch(0 0 0 / 0.2);
--shadow-sm: 0 1px 3px oklch(0 0 0 / 0.3), 0 1px 2px oklch(0 0 0 / 0.2);
--shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.3), 0 2px 4px -1px oklch(0 0 0 / 0.2);
--shadow-lg: 0 10px 15px -3px oklch(0 0 0 / 0.3), 0 4px 6px -2px oklch(0 0 0 / 0.2);
--shadow-xl: 0 20px 25px -5px oklch(0 0 0 / 0.35), 0 10px 10px -5px oklch(0 0 0 / 0.2);
--shadow-2xl: 0 25px 50px -12px oklch(0 0 0 / 0.5);
/* Brand glow (brighter for dark) */
--shadow-brand: 0 0 0 1px oklch(0.78 0.15 75 / 0.2),
0 4px 16px oklch(0.78 0.15 75 / 0.2);
--shadow-brand-lg: 0 0 0 1px oklch(0.78 0.15 75 / 0.25),
0 8px 32px oklch(0.78 0.15 75 / 0.25);
```
---
## Motion & Animation
### Durations
```css
--duration-instant: 0ms;
--duration-fast: 100ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--duration-slower: 500ms;
--duration-slowest: 700ms;
```
### Easings
```css
--ease-linear: linear;
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
```
### Common Animations
```css
/* Fade */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Slide up */
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Scale */
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Pulse (for loading states) */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Shimmer (skeleton loading) */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
```
---
## Component Patterns
### Button Variants
```tsx
// Primary (brand amber)
<Button variant="brand">Get Started</Button>
// Default (dark)
<Button variant="default">Submit</Button>
// Secondary (light)
<Button variant="secondary">Cancel</Button>
// Ghost (transparent)
<Button variant="ghost">Menu</Button>
// Outline (bordered)
<Button variant="outline">Learn More</Button>
// Destructive (red)
<Button variant="destructive">Delete</Button>
```
### Card Styles
```tsx
// Default card
<Card className="bg-card shadow-sm hover:shadow-md transition-shadow">
// Elevated card (more prominent)
<Card className="bg-card shadow-lg">
// Interactive card
<Card className="bg-card shadow-sm hover:shadow-lg hover:border-brand/20 transition-all cursor-pointer">
// Brand accent card
<Card className="bg-gradient-to-br from-brand/5 to-brand/10 border-brand/20">
```
### Input Styles
```tsx
// Default input
<Input className="border-input focus:border-brand focus:ring-brand/20" />
// With icon
<div className="relative">
<Icon className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-10" />
</div>
```
---
## Gradients
### Brand Gradients
```css
/* Subtle background */
--gradient-brand-subtle: linear-gradient(135deg,
oklch(0.75 0.16 75 / 0.05) 0%,
oklch(0.75 0.16 75 / 0.1) 100%);
/* Hero gradient */
--gradient-brand-hero: linear-gradient(135deg,
oklch(0.75 0.16 75) 0%,
oklch(0.70 0.18 65) 100%);
/* Text gradient */
--gradient-brand-text: linear-gradient(90deg,
oklch(0.70 0.18 75) 0%,
oklch(0.65 0.16 85) 100%);
```
### Background Gradients
```css
/* Warm fade (light mode) */
--gradient-warm: linear-gradient(180deg,
oklch(0.99 0.002 75) 0%,
oklch(0.975 0.006 75) 100%);
/* Dark gradient */
--gradient-dark: linear-gradient(180deg,
oklch(0.14 0.008 75) 0%,
oklch(0.10 0.006 75) 100%);
```
---
## Iconography
### Icon Library
- Primary: **Lucide React** (consistent with shadcn/ui)
- Size scale: 16px, 20px, 24px, 32px
- Stroke width: 1.5px (default), 2px (bold)
### Icon Colors
```css
/* Default */
.icon { color: var(--foreground); }
/* Muted */
.icon-muted { color: var(--muted-foreground); }
/* Brand */
.icon-brand { color: var(--brand); }
/* On interactive elements */
.btn:hover .icon { color: var(--brand); }
```
---
## Usage Guidelines
### Do
- Use warm stone colors for neutrals
- Use amber brand color for primary CTAs and accents
- Maintain consistent spacing (multiples of 4px)
- Use subtle shadows and transitions
- Ensure 4.5:1 contrast ratio for text
### Don't
- Mix cold grays with warm neutrals
- Overuse the brand amber color
- Use pure black (#000) or white (#fff)
- Add excessive shadows or effects
- Forget dark mode support
---
## Implementation
See `globals.css` for the complete CSS implementation with all variables defined.

View File

@@ -98,6 +98,7 @@ export interface Database {
tech_stack: string[]
platform: string | null
status: 'active' | 'archived' | 'paused'
phase_metadata: Json | null
created_at: string
updated_at: string
}
@@ -111,6 +112,7 @@ export interface Database {
tech_stack?: string[]
platform?: string | null
status?: 'active' | 'archived' | 'paused'
phase_metadata?: Json | null
created_at?: string
updated_at?: string
}
@@ -124,6 +126,7 @@ export interface Database {
tech_stack?: string[]
platform?: string | null
status?: 'active' | 'archived' | 'paused'
phase_metadata?: Json | null
created_at?: string
updated_at?: string
}
@@ -233,6 +236,47 @@ export interface Database {
updated_at?: string
}
}
subscriptions: {
Row: {
id: string
user_id: string
stripe_customer_id: string | null
stripe_subscription_id: string | null
plan: 'free' | 'pro' | 'team'
status: 'active' | 'canceled' | 'past_due' | 'incomplete'
current_period_start: string | null
current_period_end: string | null
cancel_at_period_end: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
user_id: string
stripe_customer_id?: string | null
stripe_subscription_id?: string | null
plan?: 'free' | 'pro' | 'team'
status?: 'active' | 'canceled' | 'past_due' | 'incomplete'
current_period_start?: string | null
current_period_end?: string | null
cancel_at_period_end?: boolean
created_at?: string
updated_at?: string
}
Update: {
id?: string
user_id?: string
stripe_customer_id?: string | null
stripe_subscription_id?: string | null
plan?: 'free' | 'pro' | 'team'
status?: 'active' | 'canceled' | 'past_due' | 'incomplete'
current_period_start?: string | null
current_period_end?: string | null
cancel_at_period_end?: boolean
created_at?: string
updated_at?: string
}
}
}
Views: {
[_ in never]: never

View File

@@ -0,0 +1,70 @@
-- Add phase_metadata column for agentic workflow tracking
ALTER TABLE projects ADD COLUMN IF NOT EXISTS phase_metadata JSONB DEFAULT '{
"currentPhase": "think",
"completedPhases": [],
"phaseStatus": "not_started",
"activities": [],
"metrics": {
"totalDuration": 0,
"phaseDurations": {},
"iterationCount": 0
}
}'::jsonb;
-- Create index for efficient querying
CREATE INDEX IF NOT EXISTS idx_projects_phase_metadata ON projects USING gin(phase_metadata);
-- Add comment for documentation
COMMENT ON COLUMN projects.phase_metadata IS 'Stores agentic development loop state: currentPhase, completedPhases, phaseStatus, activities, metrics';
-- Create function to update phase
CREATE OR REPLACE FUNCTION update_project_phase(
p_project_id UUID,
p_new_phase TEXT,
p_status TEXT DEFAULT 'in_progress'
)
RETURNS JSONB AS $$
DECLARE
v_current_metadata JSONB;
v_current_phase TEXT;
v_completed_phases JSONB;
v_new_activity JSONB;
v_updated_metadata JSONB;
BEGIN
-- Get current metadata
SELECT phase_metadata INTO v_current_metadata
FROM projects WHERE id = p_project_id;
v_current_phase := v_current_metadata->>'currentPhase';
v_completed_phases := COALESCE(v_current_metadata->'completedPhases', '[]'::jsonb);
-- Add current phase to completed if transitioning
IF v_current_phase IS NOT NULL AND v_current_phase != p_new_phase THEN
v_completed_phases := v_completed_phases || to_jsonb(v_current_phase);
END IF;
-- Create activity record
v_new_activity := jsonb_build_object(
'id', gen_random_uuid(),
'type', 'phase_change',
'message', 'Transitioned to ' || p_new_phase || ' phase',
'timestamp', now()
);
-- Build updated metadata
v_updated_metadata := jsonb_build_object(
'currentPhase', p_new_phase,
'completedPhases', v_completed_phases,
'phaseStatus', p_status,
'activities', (v_new_activity || COALESCE(v_current_metadata->'activities', '[]'::jsonb))[0:49],
'metrics', COALESCE(v_current_metadata->'metrics', '{}'::jsonb)
);
-- Update project
UPDATE projects
SET phase_metadata = v_updated_metadata, updated_at = now()
WHERE id = p_project_id;
RETURN v_updated_metadata;
END;
$$ LANGUAGE plpgsql;