fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen
- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler - Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto - Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example Made-with: Cursor
This commit is contained in:
25
client/.env.example
Normal file
25
client/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Kopiere nach .env — Vite neu starten nach Änderungen.
|
||||
|
||||
VITE_APPWRITE_PROJECT_ID=
|
||||
|
||||
# Wähle EINE Variante:
|
||||
|
||||
# B) Self-hosted / nur Produktions-Domain in Appwrite erlaubt → Vite leitet /v1 weiter (kein CORS-Problem)
|
||||
# APPWRITE_DEV_ORIGIN=https://dein-appwrite-host.tld
|
||||
# VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1
|
||||
|
||||
# A) Appwrite-Webplattform enthält „localhost“ (oder 127.0.0.1) → direkte URL, APPWRITE_DEV_ORIGIN leer lassen
|
||||
# APPWRITE_DEV_ORIGIN=
|
||||
# VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
|
||||
|
||||
# Cloud-Beispiel (ohne Proxy):
|
||||
# APPWRITE_DEV_ORIGIN=
|
||||
# VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
|
||||
# Backend: leer = Browser ruft /api auf (Vite-Proxy → 127.0.0.1:3000/api/…).
|
||||
# Nicht VITE_APPWRITE_ENDPOINT (/v1) als VITE_API_URL verwenden — sonst 404.
|
||||
# Wenn /api 404: nur EIN Vite auf 5173 (strictPort) — anderen Prozess auf 5173 beenden oder FRONTEND_URL anpassen.
|
||||
# Wenn /api im Browser 404 liefert: PORT prüfen und ggf. Proxy-Ziel setzen:
|
||||
# VITE_DEV_API_ORIGIN=http://127.0.0.1:3000
|
||||
# Optional direkt (ohne Proxy): http://localhost:3000 — fehlendes /api wird ergänzt.
|
||||
# VITE_API_URL=
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "emailsorter-client",
|
||||
"name": "mailflow-client",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "emailsorter-client",
|
||||
"name": "mailflow-client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { AppwriteException } from 'appwrite'
|
||||
import { auth } from '@/lib/appwrite'
|
||||
import type { Models } from 'appwrite'
|
||||
|
||||
function mapLoginError(err: unknown): Error {
|
||||
if (err instanceof AppwriteException) {
|
||||
const type = (err.type || '').toLowerCase()
|
||||
const msg = (err.message || '').toLowerCase()
|
||||
if (type.includes('user_blocked') || msg.includes('blocked')) {
|
||||
return new Error('Dieser Account ist gesperrt. Bitte Support kontaktieren.')
|
||||
}
|
||||
if (err.code === 401) {
|
||||
return new Error(
|
||||
'Anmeldung fehlgeschlagen. Häufige Ursachen: falsches Passwort; User wurde nur in der Console angelegt ohne Passwort; E-Mail/Passwort-Login ist im Appwrite-Projekt deaktiviert; oder die VITE_APPWRITE_PROJECT_ID passt nicht zu dem Projekt, in dem der User liegt.'
|
||||
)
|
||||
}
|
||||
if (err.message) {
|
||||
return new Error(err.message)
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err
|
||||
}
|
||||
return new Error('Login fehlgeschlagen.')
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: Models.User<Models.Preferences> | null
|
||||
loading: boolean
|
||||
@@ -36,13 +59,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [])
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
await auth.login(email, password)
|
||||
await refreshUser()
|
||||
try {
|
||||
await auth.login(email, password)
|
||||
await refreshUser()
|
||||
} catch (e) {
|
||||
throw mapLoginError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (email: string, password: string, name?: string) => {
|
||||
await auth.register(email, password, name)
|
||||
await refreshUser()
|
||||
try {
|
||||
await auth.register(email, password, name)
|
||||
await refreshUser()
|
||||
} catch (e) {
|
||||
throw mapLoginError(e)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getApiJwt } from './appwrite'
|
||||
|
||||
/**
|
||||
* Analytics & Tracking Utility
|
||||
* Handles UTM parameter tracking and event analytics
|
||||
@@ -162,17 +164,17 @@ export async function trackEvent(
|
||||
}
|
||||
|
||||
try {
|
||||
// Send to your analytics endpoint
|
||||
const jwt = await getApiJwt()
|
||||
if (!jwt) return
|
||||
|
||||
await fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {
|
||||
// Silently fail if analytics endpoint doesn't exist yet
|
||||
// This allows graceful degradation
|
||||
})
|
||||
}).catch(() => {})
|
||||
|
||||
// Also log to console in development
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
@@ -1,4 +1,62 @@
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||||
import { getApiJwt } from './appwrite'
|
||||
|
||||
/**
|
||||
* Endpoints in this file are paths like `/subscription/status`.
|
||||
* Express mounts the API under `/api`, so the base must end with `/api`.
|
||||
* If VITE_API_URL is `http://localhost:3000` (missing /api), requests would 404.
|
||||
*/
|
||||
function resolveApiBase(): string {
|
||||
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim()
|
||||
if (!raw) return '/api'
|
||||
|
||||
if (raw.startsWith('/')) {
|
||||
const p = raw.replace(/\/+$/, '') || '/api'
|
||||
return p
|
||||
}
|
||||
|
||||
if (!/^https?:\/\//i.test(raw)) {
|
||||
const p = raw.replace(/^\/+/, '').replace(/\/+$/, '') || 'api'
|
||||
return p.startsWith('api') ? `/${p}` : `/api`
|
||||
}
|
||||
|
||||
const normalized = raw.replace(/\/+$/, '')
|
||||
try {
|
||||
const u = new URL(normalized)
|
||||
const path = u.pathname.replace(/\/$/, '') || '/'
|
||||
// Same host as Vite + /v1 = Appwrite-Proxy; never append /api (would hit /v1/api/… → 404)
|
||||
const localVite =
|
||||
/^(localhost|127\.0\.0\.1)$/i.test(u.hostname) &&
|
||||
(u.port === '5173' || (u.port === '' && u.hostname === 'localhost'))
|
||||
if (path === '/v1' && localVite) {
|
||||
return '/api'
|
||||
}
|
||||
if (path === '/' || path === '') {
|
||||
return `${normalized}/api`
|
||||
}
|
||||
if (path.endsWith('/api')) {
|
||||
return normalized
|
||||
}
|
||||
return `${normalized}/api`
|
||||
} catch {
|
||||
return '/api'
|
||||
}
|
||||
}
|
||||
|
||||
const API_BASE = resolveApiBase()
|
||||
|
||||
/** Root-relative or absolute API URL; avoids `api/foo` (relative to current route). */
|
||||
function joinApiUrl(base: string, endpoint: string): string {
|
||||
const ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
|
||||
if (/^https?:\/\//i.test(base)) {
|
||||
return `${base.replace(/\/+$/, '')}${ep}`
|
||||
}
|
||||
let b = base.trim()
|
||||
if (!b.startsWith('/')) {
|
||||
b = `/${b.replace(/^\/+/, '')}`
|
||||
}
|
||||
b = b.replace(/\/+$/, '') || '/api'
|
||||
return `${b}${ep}`
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success?: boolean
|
||||
@@ -17,32 +75,58 @@ async function fetchApi<T>(
|
||||
options?: RequestInit
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
const headers: Record<string, string> = {
|
||||
...(options?.headers as Record<string, string>),
|
||||
}
|
||||
if (!headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
const jwt = await getApiJwt()
|
||||
if (jwt) {
|
||||
headers['Authorization'] = `Bearer ${jwt}`
|
||||
}
|
||||
|
||||
const urlJoined = joinApiUrl(API_BASE, endpoint)
|
||||
|
||||
const response = await fetch(urlJoined, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
headers,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
const ct = response.headers.get('content-type') || ''
|
||||
const isJson = ct.includes('application/json')
|
||||
const data = isJson
|
||||
? await response.json()
|
||||
: { success: false as const, error: undefined }
|
||||
|
||||
if (!response.ok || data.success === false) {
|
||||
return {
|
||||
error: data.error || {
|
||||
code: 'UNKNOWN',
|
||||
message: `HTTP ${response.status}`
|
||||
}
|
||||
if (!isJson) {
|
||||
return {
|
||||
error: {
|
||||
code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE',
|
||||
message:
|
||||
response.status === 404
|
||||
? 'API 404: backend unreachable or wrong port — check server is running and VITE_DEV_API_ORIGIN matches PORT.'
|
||||
: `Expected JSON, got ${ct || 'unknown'} (HTTP ${response.status})`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: data.data || data }
|
||||
if (!response.ok || data.success === false) {
|
||||
return {
|
||||
error: data.error || {
|
||||
code: 'UNKNOWN',
|
||||
message: `HTTP ${response.status}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: data.data ?? data }
|
||||
} catch (error) {
|
||||
return {
|
||||
error: {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Network error'
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: error instanceof Error ? error.message : 'Network error',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,32 +135,34 @@ export const api = {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EMAIL ACCOUNTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getEmailAccounts(userId: string) {
|
||||
|
||||
async getEmailAccounts() {
|
||||
return fetchApi<Array<{
|
||||
id: string
|
||||
email: string
|
||||
provider: 'gmail' | 'outlook' | 'imap'
|
||||
connected: boolean
|
||||
lastSync?: string
|
||||
}>>(`/email/accounts?userId=${userId}`)
|
||||
}>>('/email/accounts')
|
||||
},
|
||||
|
||||
async connectEmailAccount(userId: string, provider: 'gmail' | 'outlook', email: string, accessToken: string, refreshToken?: string) {
|
||||
async connectEmailAccount(provider: 'gmail' | 'outlook', email: string, accessToken: string, refreshToken?: string) {
|
||||
return fetchApi<{ accountId: string }>('/email/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, provider, email, accessToken, refreshToken }),
|
||||
body: JSON.stringify({ provider, email, accessToken, refreshToken }),
|
||||
})
|
||||
},
|
||||
|
||||
async connectImapAccount(
|
||||
userId: string,
|
||||
params: { email: string; password: string; imapHost?: string; imapPort?: number; imapSecure?: boolean }
|
||||
) {
|
||||
async connectImapAccount(params: {
|
||||
email: string
|
||||
password: string
|
||||
imapHost?: string
|
||||
imapPort?: number
|
||||
imapSecure?: boolean
|
||||
}) {
|
||||
return fetchApi<{ accountId: string }>('/email/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
provider: 'imap',
|
||||
email: params.email,
|
||||
accessToken: params.password,
|
||||
@@ -87,8 +173,8 @@ export const api = {
|
||||
})
|
||||
},
|
||||
|
||||
async disconnectEmailAccount(accountId: string, userId: string) {
|
||||
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, {
|
||||
async disconnectEmailAccount(accountId: string) {
|
||||
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
@@ -97,17 +183,17 @@ export const api = {
|
||||
// EMAIL STATS & SORTING
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getEmailStats(userId: string) {
|
||||
async getEmailStats() {
|
||||
return fetchApi<{
|
||||
totalSorted: number
|
||||
todaySorted: number
|
||||
weekSorted: number
|
||||
categories: Record<string, number>
|
||||
timeSaved: number
|
||||
}>(`/email/stats?userId=${userId}`)
|
||||
}>('/email/stats')
|
||||
},
|
||||
|
||||
async sortEmails(userId: string, accountId: string, maxEmails?: number, processAll?: boolean) {
|
||||
async sortEmails(accountId: string, maxEmails?: number, processAll?: boolean) {
|
||||
return fetchApi<{
|
||||
sorted: number
|
||||
inboxCleared: number
|
||||
@@ -127,11 +213,10 @@ export const api = {
|
||||
}>
|
||||
}>('/email/sort', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, accountId, maxEmails, processAll }),
|
||||
body: JSON.stringify({ accountId, maxEmails, processAll }),
|
||||
})
|
||||
},
|
||||
|
||||
// Demo sorting without account (for quick tests)
|
||||
async sortDemo(count: number = 10) {
|
||||
return fetchApi<{
|
||||
sorted: number
|
||||
@@ -152,8 +237,7 @@ export const api = {
|
||||
})
|
||||
},
|
||||
|
||||
// Connect demo account
|
||||
async connectDemoAccount(userId: string) {
|
||||
async connectDemoAccount() {
|
||||
return fetchApi<{
|
||||
accountId: string
|
||||
email: string
|
||||
@@ -161,11 +245,10 @@ export const api = {
|
||||
message?: string
|
||||
}>('/email/connect-demo', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
},
|
||||
|
||||
// Get categories
|
||||
async getCategories() {
|
||||
return fetchApi<Array<{
|
||||
id: string
|
||||
@@ -177,8 +260,7 @@ export const api = {
|
||||
}>>('/email/categories')
|
||||
},
|
||||
|
||||
// Get today's digest
|
||||
async getDigest(userId: string) {
|
||||
async getDigest() {
|
||||
return fetchApi<{
|
||||
date: string
|
||||
totalSorted: number
|
||||
@@ -188,11 +270,10 @@ export const api = {
|
||||
highlights: Array<{ type: string; count: number; message: string }>
|
||||
suggestions: Array<{ type: string; message: string }>
|
||||
hasData: boolean
|
||||
}>(`/email/digest?userId=${userId}`)
|
||||
}>('/email/digest')
|
||||
},
|
||||
|
||||
// Get digest history
|
||||
async getDigestHistory(userId: string, days: number = 7) {
|
||||
async getDigestHistory(days: number = 7) {
|
||||
return fetchApi<{
|
||||
days: number
|
||||
digests: Array<{
|
||||
@@ -207,15 +288,15 @@ export const api = {
|
||||
inboxCleared: number
|
||||
timeSavedMinutes: number
|
||||
}
|
||||
}>(`/email/digest/history?userId=${userId}&days=${days}`)
|
||||
}>(`/email/digest/history?days=${days}`)
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// OAUTH
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getOAuthUrl(provider: 'gmail' | 'outlook', userId: string) {
|
||||
return fetchApi<{ url: string }>(`/oauth/${provider}/connect?userId=${userId}`)
|
||||
async getOAuthUrl(provider: 'gmail' | 'outlook') {
|
||||
return fetchApi<{ url: string }>(`/oauth/${provider}/connect`)
|
||||
},
|
||||
|
||||
async getOAuthStatus() {
|
||||
@@ -229,10 +310,11 @@ export const api = {
|
||||
// SUBSCRIPTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getSubscriptionStatus(userId: string) {
|
||||
async getSubscriptionStatus() {
|
||||
return fetchApi<{
|
||||
status: string
|
||||
plan: string
|
||||
planDisplayName?: string
|
||||
isFreeTier: boolean
|
||||
emailsUsedThisMonth?: number
|
||||
emailsLimit?: number
|
||||
@@ -245,34 +327,34 @@ export const api = {
|
||||
}
|
||||
currentPeriodEnd?: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
}>(`/subscription/status?userId=${userId}`)
|
||||
}>('/subscription/status')
|
||||
},
|
||||
|
||||
async createSubscriptionCheckout(plan: string, userId: string, email?: string) {
|
||||
async createSubscriptionCheckout(plan: string, email?: string) {
|
||||
return fetchApi<{ url: string; sessionId: string }>('/subscription/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, plan, email }),
|
||||
body: JSON.stringify({ plan, email }),
|
||||
})
|
||||
},
|
||||
|
||||
async createPortalSession(userId: string) {
|
||||
async createPortalSession() {
|
||||
return fetchApi<{ url: string }>('/subscription/portal', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
},
|
||||
|
||||
async cancelSubscription(userId: string) {
|
||||
async cancelSubscription() {
|
||||
return fetchApi<{ success: boolean }>('/subscription/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
},
|
||||
|
||||
async reactivateSubscription(userId: string) {
|
||||
async reactivateSubscription() {
|
||||
return fetchApi<{ success: boolean }>('/subscription/reactivate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -280,16 +362,21 @@ export const api = {
|
||||
// USER PREFERENCES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getUserPreferences(userId: string) {
|
||||
async getUserPreferences() {
|
||||
return fetchApi<{
|
||||
vipSenders: Array<{ email: string; name?: string }>
|
||||
blockedSenders: string[]
|
||||
customRules: Array<{ condition: string; category: string }>
|
||||
priorityTopics: string[]
|
||||
}>(`/preferences?userId=${userId}`)
|
||||
profile?: {
|
||||
displayName?: string
|
||||
timezone?: string
|
||||
notificationPrefs?: Record<string, unknown>
|
||||
}
|
||||
}>('/preferences')
|
||||
},
|
||||
|
||||
async saveUserPreferences(userId: string, preferences: {
|
||||
async saveUserPreferences(preferences: {
|
||||
vipSenders?: Array<{ email: string; name?: string }>
|
||||
blockedSenders?: string[]
|
||||
customRules?: Array<{ condition: string; category: string }>
|
||||
@@ -298,7 +385,7 @@ export const api = {
|
||||
}) {
|
||||
return fetchApi<{ success: boolean }>('/preferences', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, ...preferences }),
|
||||
body: JSON.stringify(preferences),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -306,7 +393,7 @@ export const api = {
|
||||
// AI CONTROL
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getAIControlSettings(userId: string) {
|
||||
async getAIControlSettings() {
|
||||
return fetchApi<{
|
||||
enabledCategories: string[]
|
||||
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||
@@ -314,10 +401,10 @@ export const api = {
|
||||
cleanup?: unknown
|
||||
categoryAdvanced?: Record<string, unknown>
|
||||
version?: number
|
||||
}>(`/preferences/ai-control?userId=${userId}`)
|
||||
}>('/preferences/ai-control')
|
||||
},
|
||||
|
||||
async saveAIControlSettings(userId: string, settings: {
|
||||
async saveAIControlSettings(settings: {
|
||||
enabledCategories?: string[]
|
||||
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||
autoDetectCompanies?: boolean
|
||||
@@ -327,33 +414,24 @@ export const api = {
|
||||
}) {
|
||||
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, ...settings }),
|
||||
body: JSON.stringify(settings),
|
||||
})
|
||||
},
|
||||
|
||||
// Cleanup Preview - shows what would be cleaned up without actually doing it
|
||||
// TODO: Backend endpoint needs to be implemented
|
||||
// GET /api/preferences/ai-control/cleanup/preview?userId=xxx
|
||||
// Response: { preview: Array<{id, subject, from, date, reason}> }
|
||||
async getCleanupPreview(userId: string) {
|
||||
// TODO: Implement backend endpoint
|
||||
async getCleanupPreview(accountId: string) {
|
||||
return fetchApi<{
|
||||
preview: Array<{
|
||||
messages: Array<{
|
||||
id: string
|
||||
subject: string
|
||||
from: string
|
||||
date: string
|
||||
reason: 'read' | 'promotion'
|
||||
}>
|
||||
}>(`/preferences/ai-control/cleanup/preview?userId=${userId}`)
|
||||
count: number
|
||||
}>(`/email/${accountId}/cleanup/preview`)
|
||||
},
|
||||
|
||||
// Run cleanup now - executes cleanup for the user
|
||||
// POST /api/preferences/ai-control/cleanup/run
|
||||
// Body: { userId: string }
|
||||
// Response: { success: boolean, data: { readItems: number, promotions: number } }
|
||||
async runCleanup(userId: string) {
|
||||
// Uses existing /api/email/cleanup endpoint
|
||||
async runCleanup() {
|
||||
return fetchApi<{
|
||||
usersProcessed: number
|
||||
emailsProcessed: {
|
||||
@@ -363,40 +441,47 @@ export const api = {
|
||||
errors: Array<{ userId: string; error: string }>
|
||||
}>('/email/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
},
|
||||
|
||||
// Get cleanup status - last run info and counts
|
||||
// TODO: Backend endpoint needs to be implemented
|
||||
// GET /api/preferences/ai-control/cleanup/status?userId=xxx
|
||||
// Response: { lastRun?: string, lastRunCounts?: { readItems: number, promotions: number } }
|
||||
async getCleanupStatus(userId: string) {
|
||||
// TODO: Implement backend endpoint
|
||||
async getCleanupStatus(accountId: string) {
|
||||
return fetchApi<{
|
||||
lastRun?: string
|
||||
lastRunCounts?: {
|
||||
readItems: number
|
||||
promotions: number
|
||||
}
|
||||
}>(`/preferences/ai-control/cleanup/status?userId=${userId}`)
|
||||
lastErrors?: string[]
|
||||
}>(`/email/${accountId}/cleanup/status`)
|
||||
},
|
||||
|
||||
async updateProfile(payload: {
|
||||
displayName?: string
|
||||
timezone?: string
|
||||
notificationPrefs?: Record<string, unknown>
|
||||
}) {
|
||||
return fetchApi<{ success: boolean }>('/preferences/profile', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COMPANY LABELS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getCompanyLabels(userId: string) {
|
||||
async getCompanyLabels() {
|
||||
return fetchApi<Array<{
|
||||
id?: string
|
||||
name: string
|
||||
condition: string
|
||||
enabled: boolean
|
||||
category?: string
|
||||
}>>(`/preferences/company-labels?userId=${userId}`)
|
||||
}>>('/preferences/company-labels')
|
||||
},
|
||||
|
||||
async saveCompanyLabel(userId: string, companyLabel: {
|
||||
async saveCompanyLabel(companyLabel: {
|
||||
id?: string
|
||||
name: string
|
||||
condition: string
|
||||
@@ -411,12 +496,12 @@ export const api = {
|
||||
category?: string
|
||||
}>('/preferences/company-labels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, companyLabel }),
|
||||
body: JSON.stringify({ companyLabel }),
|
||||
})
|
||||
},
|
||||
|
||||
async deleteCompanyLabel(userId: string, labelId: string) {
|
||||
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}?userId=${userId}`, {
|
||||
async deleteCompanyLabel(labelId: string) {
|
||||
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
@@ -425,43 +510,36 @@ export const api = {
|
||||
// ME / ADMIN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getMe(email: string) {
|
||||
return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`)
|
||||
async getMe() {
|
||||
return fetchApi<{ isAdmin: boolean }>('/me')
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// NAME LABELS (Workers – Admin only)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getNameLabels(userId: string, email: string) {
|
||||
async getNameLabels() {
|
||||
return fetchApi<Array<{
|
||||
id?: string
|
||||
name: string
|
||||
email?: string
|
||||
keywords?: string[]
|
||||
enabled: boolean
|
||||
}>>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`)
|
||||
}>>('/preferences/name-labels')
|
||||
},
|
||||
|
||||
async saveNameLabel(
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }
|
||||
) {
|
||||
async saveNameLabel(nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }) {
|
||||
return fetchApi<{ id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }>(
|
||||
'/preferences/name-labels',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, email: userEmail, nameLabel }),
|
||||
body: JSON.stringify({ nameLabel }),
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
async deleteNameLabel(userId: string, userEmail: string, labelId: string) {
|
||||
return fetchApi<{ success: boolean }>(
|
||||
`/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
async deleteNameLabel(labelId: string) {
|
||||
return fetchApi<{ success: boolean }>(`/preferences/name-labels/${labelId}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -523,36 +601,36 @@ export const api = {
|
||||
// ONBOARDING
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getOnboardingStatus(userId: string) {
|
||||
async getOnboardingStatus() {
|
||||
return fetchApi<{
|
||||
onboarding_step: string
|
||||
completedSteps: string[]
|
||||
first_value_seen_at?: string
|
||||
skipped_at?: string
|
||||
}>(`/onboarding/status?userId=${userId}`)
|
||||
}>('/onboarding/status')
|
||||
},
|
||||
|
||||
async updateOnboardingStep(userId: string, step: string, completedSteps: string[] = []) {
|
||||
async updateOnboardingStep(step: string, completedSteps: string[] = []) {
|
||||
return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, step, completedSteps }),
|
||||
body: JSON.stringify({ step, completedSteps }),
|
||||
})
|
||||
},
|
||||
|
||||
async skipOnboarding(userId: string) {
|
||||
async skipOnboarding() {
|
||||
return fetchApi<{ skipped: boolean }>('/onboarding/skip', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
},
|
||||
|
||||
async resumeOnboarding(userId: string) {
|
||||
async resumeOnboarding() {
|
||||
return fetchApi<{
|
||||
onboarding_step: string
|
||||
completedSteps: string[]
|
||||
}>('/onboarding/resume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -560,10 +638,10 @@ export const api = {
|
||||
// ACCOUNT MANAGEMENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async deleteAccount(userId: string) {
|
||||
async deleteAccount() {
|
||||
return fetchApi<{ success: boolean }>('/account/delete', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ userId }),
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
},
|
||||
|
||||
@@ -571,17 +649,17 @@ export const api = {
|
||||
// REFERRALS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async getReferralCode(userId: string) {
|
||||
async getReferralCode() {
|
||||
return fetchApi<{
|
||||
referralCode: string
|
||||
referralCount: number
|
||||
}>(`/referrals/code?userId=${userId}`)
|
||||
}>('/referrals/code')
|
||||
},
|
||||
|
||||
async trackReferral(userId: string, referralCode: string) {
|
||||
async trackReferral(referralCode: string) {
|
||||
return fetchApi<{ success: boolean }>('/referrals/track', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, referralCode }),
|
||||
body: JSON.stringify({ referralCode }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,20 +4,89 @@ const client = new Client()
|
||||
|
||||
// Configure these in your .env file
|
||||
const APPWRITE_ENDPOINT = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1'
|
||||
const APPWRITE_PROJECT_ID = import.meta.env.VITE_APPWRITE_PROJECT_ID || ''
|
||||
const APPWRITE_PROJECT_ID = (import.meta.env.VITE_APPWRITE_PROJECT_ID || '').trim()
|
||||
|
||||
/** Ohne Project ID keine gültigen Appwrite-Requests — sonst nur 401-Spam im Netzwerk-Tab. */
|
||||
export function isAppwriteClientConfigured(): boolean {
|
||||
return APPWRITE_PROJECT_ID.length > 0
|
||||
}
|
||||
|
||||
function assertAppwriteConfigured(): void {
|
||||
if (!isAppwriteClientConfigured()) {
|
||||
throw new Error(
|
||||
'Appwrite ist nicht konfiguriert: Lege client/.env an und setze VITE_APPWRITE_PROJECT_ID (siehe client/.env.example). Danach Vite neu starten.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
client
|
||||
.setEndpoint(APPWRITE_ENDPOINT)
|
||||
.setProject(APPWRITE_PROJECT_ID)
|
||||
|
||||
if (import.meta.env.DEV && !isAppwriteClientConfigured()) {
|
||||
console.warn(
|
||||
'[MailFlow] VITE_APPWRITE_PROJECT_ID fehlt → keine Appwrite-Aufrufe bis client/.env gesetzt ist. Kopiere client/.env.example nach client/.env.'
|
||||
)
|
||||
}
|
||||
|
||||
/** Vite-Dev mit Proxy: Endpoint zeigt auf localhost → Session liegt in cookieFallback (localStorage). */
|
||||
function isLocalViteAppwriteProxy(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return /localhost|127\.0\.0\.1/.test(APPWRITE_ENDPOINT)
|
||||
}
|
||||
|
||||
function hasCookieFallbackSession(): boolean {
|
||||
if (typeof window === 'undefined' || !APPWRITE_PROJECT_ID) return false
|
||||
try {
|
||||
const raw = window.localStorage.getItem('cookieFallback')
|
||||
if (!raw) return false
|
||||
const o = JSON.parse(raw) as Record<string, unknown>
|
||||
return Boolean(o[`a_session_${APPWRITE_PROJECT_ID}`])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const account = new Account(client)
|
||||
export const databases = new Databases(client)
|
||||
export { ID }
|
||||
|
||||
const JWT_BUFFER_MS = 30_000
|
||||
let jwtCache: { token: string; expMs: number } | null = null
|
||||
|
||||
export function clearApiJwtCache() {
|
||||
jwtCache = null
|
||||
}
|
||||
|
||||
/** Short-lived JWT for MailFlow API (Bearer). Cached until near expiry. */
|
||||
export async function getApiJwt(): Promise<string | null> {
|
||||
if (!isAppwriteClientConfigured()) {
|
||||
return null
|
||||
}
|
||||
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const now = Date.now()
|
||||
if (jwtCache && jwtCache.expMs > now + JWT_BUFFER_MS) {
|
||||
return jwtCache.token
|
||||
}
|
||||
const res = await account.createJWT()
|
||||
const token = res.jwt
|
||||
const expireSec = (res as { expire?: number }).expire
|
||||
const expMs = expireSec != null ? expireSec * 1000 : now + 14 * 60 * 1000
|
||||
jwtCache = { token, expMs }
|
||||
return token
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Auth helper functions
|
||||
export const auth = {
|
||||
// Create a new account
|
||||
async register(email: string, password: string, name?: string) {
|
||||
assertAppwriteConfigured()
|
||||
const user = await account.create(ID.unique(), email, password, name)
|
||||
await this.login(email, password)
|
||||
return user
|
||||
@@ -25,16 +94,27 @@ export const auth = {
|
||||
|
||||
// Login with email and password
|
||||
async login(email: string, password: string) {
|
||||
assertAppwriteConfigured()
|
||||
return await account.createEmailPasswordSession(email, password)
|
||||
},
|
||||
|
||||
// Logout current session
|
||||
async logout() {
|
||||
clearApiJwtCache()
|
||||
if (!isAppwriteClientConfigured()) {
|
||||
return
|
||||
}
|
||||
return await account.deleteSession('current')
|
||||
},
|
||||
|
||||
// Get current logged in user
|
||||
async getCurrentUser() {
|
||||
if (!isAppwriteClientConfigured()) {
|
||||
return null
|
||||
}
|
||||
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await account.get()
|
||||
} catch {
|
||||
@@ -44,6 +124,12 @@ export const auth = {
|
||||
|
||||
// Check if user is logged in
|
||||
async isLoggedIn() {
|
||||
if (!isAppwriteClientConfigured()) {
|
||||
return false
|
||||
}
|
||||
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await account.get()
|
||||
return true
|
||||
@@ -54,6 +140,7 @@ export const auth = {
|
||||
|
||||
// Send password recovery email
|
||||
async forgotPassword(email: string) {
|
||||
assertAppwriteConfigured()
|
||||
return await account.createRecovery(
|
||||
email,
|
||||
`${window.location.origin}/reset-password`
|
||||
@@ -62,11 +149,13 @@ export const auth = {
|
||||
|
||||
// Complete password recovery
|
||||
async resetPassword(userId: string, secret: string, newPassword: string) {
|
||||
assertAppwriteConfigured()
|
||||
return await account.updateRecovery(userId, secret, newPassword)
|
||||
},
|
||||
|
||||
// Send verification email
|
||||
async sendVerification() {
|
||||
assertAppwriteConfigured()
|
||||
return await account.createVerification(
|
||||
`${window.location.origin}/verify`
|
||||
)
|
||||
@@ -74,6 +163,7 @@ export const auth = {
|
||||
|
||||
// Complete email verification
|
||||
async verifyEmail(userId: string, secret: string) {
|
||||
assertAppwriteConfigured()
|
||||
return await account.updateVerification(userId, secret)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ export function Dashboard() {
|
||||
const [digest, setDigest] = useState<Digest | null>(null)
|
||||
const [subscription, setSubscription] = useState<{
|
||||
plan: string
|
||||
planDisplayName?: string
|
||||
isFreeTier: boolean
|
||||
emailsUsedThisMonth?: number
|
||||
emailsLimit?: number
|
||||
@@ -115,11 +116,11 @@ export function Dashboard() {
|
||||
|
||||
try {
|
||||
const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([
|
||||
api.getEmailStats(user.$id),
|
||||
api.getEmailAccounts(user.$id),
|
||||
api.getDigest(user.$id),
|
||||
api.getSubscriptionStatus(user.$id),
|
||||
api.getReferralCode(user.$id).catch(() => ({ data: null })),
|
||||
api.getEmailStats(),
|
||||
api.getEmailAccounts(),
|
||||
api.getDigest(),
|
||||
api.getSubscriptionStatus(),
|
||||
api.getReferralCode().catch(() => ({ data: null })),
|
||||
])
|
||||
|
||||
if (statsRes.data) setStats(statsRes.data)
|
||||
@@ -146,7 +147,7 @@ export function Dashboard() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await api.sortEmails(user.$id, accounts[0].id)
|
||||
const result = await api.sortEmails(accounts[0].id)
|
||||
if (result.data) {
|
||||
setSortResult(result.data)
|
||||
|
||||
@@ -155,9 +156,9 @@ export function Dashboard() {
|
||||
|
||||
// Refresh stats, digest, and subscription
|
||||
const [statsRes, digestRes, subscriptionRes] = await Promise.all([
|
||||
api.getEmailStats(user.$id),
|
||||
api.getDigest(user.$id),
|
||||
api.getSubscriptionStatus(user.$id),
|
||||
api.getEmailStats(),
|
||||
api.getDigest(),
|
||||
api.getSubscriptionStatus(),
|
||||
])
|
||||
if (statsRes.data) setStats(statsRes.data)
|
||||
if (digestRes.data) setDigest(digestRes.data)
|
||||
@@ -168,7 +169,7 @@ export function Dashboard() {
|
||||
setError(result.error.message || 'Monthly limit reached')
|
||||
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
|
||||
// Refresh subscription to show updated usage
|
||||
const subscriptionRes = await api.getSubscriptionStatus(user.$id)
|
||||
const subscriptionRes = await api.getSubscriptionStatus()
|
||||
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
|
||||
} else {
|
||||
setError(result.error.message || 'Email sorting failed. Please try again or reconnect your account.')
|
||||
@@ -504,7 +505,7 @@ export function Dashboard() {
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await api.saveUserPreferences(user.$id, updates)
|
||||
await api.saveUserPreferences(updates)
|
||||
trackRulesApplied(user.$id, sortResult.suggestedRules.length)
|
||||
showMessage('success', `${sortResult.suggestedRules.length} rules applied. Your inbox will stay organized.`)
|
||||
setSortResult({ ...sortResult, suggestedRules: [] })
|
||||
@@ -834,7 +835,12 @@ export function Dashboard() {
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">Subscription</p>
|
||||
<Badge variant={subscription?.isFreeTier ? 'secondary' : 'default'} className="text-xs">
|
||||
{subscription?.plan === 'free' ? 'Free Tier' : subscription?.plan || 'Free Tier'}
|
||||
{subscription?.isFreeTier
|
||||
? 'Free Tier'
|
||||
: subscription?.planDisplayName ||
|
||||
(subscription?.plan
|
||||
? subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
|
||||
: 'Business')}
|
||||
</Badge>
|
||||
</div>
|
||||
{subscription?.isFreeTier && subscription.emailsLimit && (
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Register() {
|
||||
useEffect(() => {
|
||||
if (user?.$id && referralCode) {
|
||||
// Track referral if code exists
|
||||
api.trackReferral(user.$id, referralCode).catch((err) => {
|
||||
api.trackReferral(referralCode).catch((err) => {
|
||||
console.error('Failed to track referral:', err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,12 +60,74 @@ import { PrivacySecurity } from '@/components/PrivacySecurity'
|
||||
|
||||
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals'
|
||||
|
||||
const HEX_COLOR = /^#([0-9A-Fa-f]{6})$/
|
||||
|
||||
function validateLabelImport(
|
||||
imported: unknown,
|
||||
existing: CompanyLabel[]
|
||||
): { labels: CompanyLabel[]; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
if (!Array.isArray(imported)) {
|
||||
return { labels: [], errors: ['File must contain a JSON array'] }
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const labels: CompanyLabel[] = []
|
||||
imported.forEach((row, i) => {
|
||||
const rowNum = i + 1
|
||||
if (!row || typeof row !== 'object') {
|
||||
errors.push(`Row ${rowNum}: invalid object`)
|
||||
return
|
||||
}
|
||||
const r = row as Record<string, unknown>
|
||||
const name = typeof r.name === 'string' ? r.name.trim() : ''
|
||||
if (!name) {
|
||||
errors.push(`Row ${rowNum}: name is required`)
|
||||
return
|
||||
}
|
||||
if (name.length > 50) {
|
||||
errors.push(`Row ${rowNum}: name must be at most 50 characters`)
|
||||
return
|
||||
}
|
||||
if (r.color != null && r.color !== '') {
|
||||
if (typeof r.color !== 'string' || !HEX_COLOR.test(r.color)) {
|
||||
errors.push(`Row ${rowNum}: color must be a valid #RRGGBB hex`)
|
||||
return
|
||||
}
|
||||
}
|
||||
const key = name.toLowerCase()
|
||||
if (seen.has(key)) {
|
||||
errors.push(`Row ${rowNum}: duplicate name "${name}" in import`)
|
||||
return
|
||||
}
|
||||
seen.add(key)
|
||||
if (existing.some((e) => e.name.trim().toLowerCase() === key)) {
|
||||
errors.push(`Row ${rowNum}: name "${name}" already exists`)
|
||||
return
|
||||
}
|
||||
labels.push({
|
||||
id: typeof r.id === 'string' && r.id ? r.id : `label_import_${Date.now()}_${i}`,
|
||||
name,
|
||||
condition: typeof r.condition === 'string' ? r.condition : '',
|
||||
enabled: r.enabled !== false,
|
||||
category: typeof r.category === 'string' ? r.category : 'promotions',
|
||||
})
|
||||
})
|
||||
if (existing.length + labels.length > 100) {
|
||||
return {
|
||||
labels: [],
|
||||
errors: [`Cannot exceed 100 labels total (have ${existing.length}, importing ${labels.length})`],
|
||||
}
|
||||
}
|
||||
return { labels, errors }
|
||||
}
|
||||
|
||||
interface EmailAccount {
|
||||
id: string
|
||||
email: string
|
||||
provider: 'gmail' | 'outlook' | 'imap'
|
||||
provider: 'gmail' | 'outlook' | 'imap' | 'demo'
|
||||
connected: boolean
|
||||
lastSync?: string
|
||||
isDemo?: boolean
|
||||
}
|
||||
|
||||
interface VIPSender {
|
||||
@@ -76,10 +138,35 @@ interface VIPSender {
|
||||
interface Subscription {
|
||||
status: string
|
||||
plan: string
|
||||
planDisplayName?: string
|
||||
isFreeTier?: boolean
|
||||
currentPeriodEnd?: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
}
|
||||
|
||||
function subscriptionTitle(sub: Subscription | null): string {
|
||||
if (!sub) return ''
|
||||
if (sub.planDisplayName) return sub.planDisplayName
|
||||
if (sub.plan === 'free' || sub.isFreeTier) return 'Free plan'
|
||||
if (sub.plan) return sub.plan.charAt(0).toUpperCase() + sub.plan.slice(1)
|
||||
return 'Subscription'
|
||||
}
|
||||
|
||||
function subscriptionBadge(sub: Subscription | null): {
|
||||
label: string
|
||||
variant: 'success' | 'warning' | 'secondary'
|
||||
} {
|
||||
if (!sub) return { label: '', variant: 'secondary' }
|
||||
if (sub.isFreeTier) return { label: 'Free plan', variant: 'secondary' }
|
||||
if (sub.status === 'active') return { label: 'Active', variant: 'success' }
|
||||
const s = (sub.status || '').toLowerCase()
|
||||
if (s === 'trialing' || s === 'trial') return { label: 'Trial', variant: 'warning' }
|
||||
return {
|
||||
label: sub.status ? sub.status.charAt(0).toUpperCase() + sub.status.slice(1) : 'Inactive',
|
||||
variant: 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
@@ -130,6 +217,7 @@ export function Settings() {
|
||||
})
|
||||
const [categories, setCategories] = useState<CategoryInfo[]>([])
|
||||
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
|
||||
const [labelImportErrors, setLabelImportErrors] = useState<string[]>([])
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
|
||||
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
|
||||
@@ -174,11 +262,24 @@ export function Settings() {
|
||||
}
|
||||
}, [user])
|
||||
|
||||
// Refetch subscription when opening this tab (fixes JWT timing vs initial loadData)
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'subscription' || !user?.$id) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const res = await api.getSubscriptionStatus()
|
||||
if (!cancelled && res.data) setSubscription(res.data)
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeTab, user?.$id])
|
||||
|
||||
const loadReferralData = async () => {
|
||||
if (!user?.$id) return
|
||||
setLoadingReferral(true)
|
||||
try {
|
||||
const res = await api.getReferralCode(user.$id)
|
||||
const res = await api.getReferralCode()
|
||||
if (res.data) setReferralData(res.data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load referral data:', err)
|
||||
@@ -194,24 +295,33 @@ export function Settings() {
|
||||
|
||||
try {
|
||||
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
|
||||
api.getEmailAccounts(user.$id),
|
||||
api.getSubscriptionStatus(user.$id),
|
||||
api.getUserPreferences(user.$id),
|
||||
api.getAIControlSettings(user.$id),
|
||||
api.getCompanyLabels(user.$id),
|
||||
user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }),
|
||||
api.getEmailAccounts(),
|
||||
api.getSubscriptionStatus(),
|
||||
api.getUserPreferences(),
|
||||
api.getAIControlSettings(),
|
||||
api.getCompanyLabels(),
|
||||
user?.$id ? api.getMe() : Promise.resolve({ data: { isAdmin: false } }),
|
||||
])
|
||||
|
||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||
if (subsRes.data) setSubscription(subsRes.data)
|
||||
if (meRes.data?.isAdmin) {
|
||||
setIsAdmin(true)
|
||||
const nameLabelsRes = await api.getNameLabels(user.$id, user.email)
|
||||
const nameLabelsRes = await api.getNameLabels()
|
||||
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
|
||||
} else {
|
||||
setIsAdmin(false)
|
||||
}
|
||||
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
|
||||
const pdata = prefsRes.data as {
|
||||
profile?: { displayName?: string; timezone?: string; notificationPrefs?: { language?: string } }
|
||||
} | undefined
|
||||
if (pdata?.profile) {
|
||||
const prof = pdata.profile
|
||||
if (prof.displayName != null && prof.displayName !== '') setName(prof.displayName)
|
||||
if (prof.timezone) setTimezone(prof.timezone)
|
||||
if (prof.notificationPrefs?.language) setLanguage(String(prof.notificationPrefs.language))
|
||||
}
|
||||
if (aiControlRes.data) {
|
||||
// Merge cleanup defaults if not present
|
||||
const raw = aiControlRes.data
|
||||
@@ -297,7 +407,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.saveAIControlSettings(user.$id, {
|
||||
await api.saveAIControlSettings({
|
||||
enabledCategories: aiControlSettings.enabledCategories,
|
||||
categoryActions: aiControlSettings.categoryActions,
|
||||
autoDetectCompanies: aiControlSettings.autoDetectCompanies,
|
||||
@@ -326,24 +436,25 @@ export function Settings() {
|
||||
|
||||
// Load cleanup status
|
||||
const loadCleanupStatus = async () => {
|
||||
if (!user?.$id) return
|
||||
const aid = accounts.find((a) => a.provider !== 'demo')?.id
|
||||
if (!aid) return
|
||||
try {
|
||||
const res = await api.getCleanupStatus(user.$id)
|
||||
const res = await api.getCleanupStatus(aid)
|
||||
if (res.data) setCleanupStatus(res.data)
|
||||
} catch {
|
||||
// Silently fail if endpoint doesn't exist yet
|
||||
console.debug('Cleanup status endpoint not available')
|
||||
}
|
||||
}
|
||||
|
||||
// Load cleanup preview
|
||||
const loadCleanupPreview = async () => {
|
||||
if (!user?.$id || !aiControlSettings.cleanup?.enabled) return
|
||||
if (!aiControlSettings.cleanup?.enabled) return
|
||||
const aid = accounts.find((a) => a.provider !== 'demo')?.id
|
||||
if (!aid) return
|
||||
try {
|
||||
const res = await api.getCleanupPreview(user.$id)
|
||||
if (res.data?.preview) setCleanupPreview(res.data.preview)
|
||||
const res = await api.getCleanupPreview(aid)
|
||||
if (res.data?.messages) setCleanupPreview(res.data.messages)
|
||||
} catch {
|
||||
// Silently fail if endpoint doesn't exist yet
|
||||
console.debug('Cleanup preview endpoint not available')
|
||||
}
|
||||
}
|
||||
@@ -356,14 +467,14 @@ export function Settings() {
|
||||
loadCleanupPreview()
|
||||
}
|
||||
}
|
||||
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun])
|
||||
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun, accounts])
|
||||
|
||||
// Run cleanup now
|
||||
const handleRunCleanup = async () => {
|
||||
if (!user?.$id) return
|
||||
setRunningCleanup(true)
|
||||
try {
|
||||
const res = await api.runCleanup(user.$id)
|
||||
const res = await api.runCleanup()
|
||||
if (res.data) {
|
||||
showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`)
|
||||
await loadCleanupStatus()
|
||||
@@ -432,8 +543,15 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
setSaving(true)
|
||||
try {
|
||||
// TODO: Save profile data to backend
|
||||
// await api.updateUserProfile(user.$id, { name, language, timezone })
|
||||
const res = await api.updateProfile({
|
||||
displayName: name,
|
||||
timezone,
|
||||
notificationPrefs: { language },
|
||||
})
|
||||
if (res.error) {
|
||||
showMessage('error', res.error.message || 'Failed to save profile')
|
||||
return
|
||||
}
|
||||
savedProfileRef.current = { name, language, timezone }
|
||||
setHasProfileChanges(false)
|
||||
showMessage('success', 'Profile saved successfully!')
|
||||
@@ -472,7 +590,7 @@ export function Settings() {
|
||||
setConnectingProvider(provider)
|
||||
|
||||
try {
|
||||
const res = await api.getOAuthUrl(provider, user.$id)
|
||||
const res = await api.getOAuthUrl(provider)
|
||||
if (res.data?.url) {
|
||||
window.location.href = res.data.url
|
||||
}
|
||||
@@ -486,7 +604,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
await api.disconnectEmailAccount(accountId, user.$id)
|
||||
await api.disconnectEmailAccount(accountId)
|
||||
setAccounts(accounts.filter(a => a.id !== accountId))
|
||||
showMessage('success', 'Account disconnected')
|
||||
} catch {
|
||||
@@ -498,7 +616,7 @@ export function Settings() {
|
||||
e.preventDefault()
|
||||
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
|
||||
setImapConnecting(true)
|
||||
const res = await api.connectImapAccount(user.$id, {
|
||||
const res = await api.connectImapAccount({
|
||||
email: imapForm.email.trim(),
|
||||
password: imapForm.password,
|
||||
imapHost: imapForm.imapHost || undefined,
|
||||
@@ -511,7 +629,7 @@ export function Settings() {
|
||||
setImapConnecting(false)
|
||||
return
|
||||
}
|
||||
const list = await api.getEmailAccounts(user.$id)
|
||||
const list = await api.getEmailAccounts()
|
||||
setAccounts(list.data ?? [])
|
||||
setShowImapForm(false)
|
||||
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
||||
@@ -541,7 +659,7 @@ export function Settings() {
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
await api.saveUserPreferences(user.$id, { vipSenders })
|
||||
await api.saveUserPreferences({ vipSenders })
|
||||
showMessage('success', 'VIP list saved!')
|
||||
} catch {
|
||||
showMessage('error', 'Failed to save')
|
||||
@@ -554,7 +672,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
const res = await api.createPortalSession(user.$id)
|
||||
const res = await api.createPortalSession()
|
||||
if (res.data?.url) {
|
||||
window.location.href = res.data.url
|
||||
}
|
||||
@@ -567,7 +685,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
const res = await api.createSubscriptionCheckout(plan, user.$id, user.email)
|
||||
const res = await api.createSubscriptionCheckout(plan, user.email)
|
||||
if (res.data?.url) {
|
||||
window.location.href = res.data.url
|
||||
}
|
||||
@@ -1721,6 +1839,7 @@ export function Settings() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLabelImportErrors([])
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'application/json'
|
||||
@@ -1730,12 +1849,18 @@ export function Settings() {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const imported = JSON.parse(text)
|
||||
if (Array.isArray(imported)) {
|
||||
// TODO: Validate and import labels
|
||||
showMessage('success', `Imported ${imported.length} labels`)
|
||||
const { labels, errors } = validateLabelImport(imported, companyLabels)
|
||||
if (errors.length > 0) {
|
||||
setLabelImportErrors(errors)
|
||||
showMessage('error', 'Fix import errors before saving')
|
||||
return
|
||||
}
|
||||
setLabelImportErrors([])
|
||||
setCompanyLabels([...companyLabels, ...labels])
|
||||
showMessage('success', `Imported ${labels.length} labels`)
|
||||
} catch {
|
||||
showMessage('error', 'Invalid JSON file')
|
||||
setLabelImportErrors([])
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
@@ -1755,6 +1880,16 @@ export function Settings() {
|
||||
Add Label
|
||||
</Button>
|
||||
</div>
|
||||
{labelImportErrors.length > 0 && (
|
||||
<div className="mt-3 w-full rounded-md border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/40 p-3 text-sm text-red-800 dark:text-red-200">
|
||||
<p className="font-medium mb-1">Import issues</p>
|
||||
<ul className="list-disc pl-5 space-y-0.5">
|
||||
{labelImportErrors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-Detection Toggle */}
|
||||
@@ -1853,7 +1988,7 @@ export function Settings() {
|
||||
onClick={async () => {
|
||||
if (!user?.$id || !label.id) return
|
||||
try {
|
||||
await api.saveCompanyLabel(user.$id, { ...label, enabled: !label.enabled })
|
||||
await api.saveCompanyLabel({ ...label, enabled: !label.enabled })
|
||||
setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||||
showMessage('success', 'Label updated!')
|
||||
} catch {
|
||||
@@ -1874,7 +2009,7 @@ export function Settings() {
|
||||
if (!user?.$id || !label.id) return
|
||||
if (!confirm('Are you sure you want to delete this label?')) return
|
||||
try {
|
||||
await api.deleteCompanyLabel(user.$id, label.id)
|
||||
await api.deleteCompanyLabel(label.id)
|
||||
setCompanyLabels(companyLabels.filter(l => l.id !== label.id))
|
||||
showMessage('success', 'Label deleted!')
|
||||
} catch {
|
||||
@@ -2163,7 +2298,7 @@ export function Settings() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await api.saveCompanyLabel(user.$id, editingLabel)
|
||||
const saved = await api.saveCompanyLabel(editingLabel)
|
||||
if (saved.data) {
|
||||
if (editingLabel.id) {
|
||||
setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l))
|
||||
@@ -2235,7 +2370,7 @@ export function Settings() {
|
||||
onClick={async () => {
|
||||
if (!user?.$id || !label.id) return
|
||||
try {
|
||||
await api.saveNameLabel(user.$id, user.email, { ...label, enabled: !label.enabled })
|
||||
await api.saveNameLabel({ ...label, enabled: !label.enabled })
|
||||
setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||||
showMessage('success', 'Label updated!')
|
||||
} catch {
|
||||
@@ -2256,7 +2391,7 @@ export function Settings() {
|
||||
if (!user?.$id || !label.id) return
|
||||
if (!confirm('Delete this name label?')) return
|
||||
try {
|
||||
await api.deleteNameLabel(user.$id, user.email, label.id)
|
||||
await api.deleteNameLabel(label.id)
|
||||
setNameLabels(nameLabels.filter(l => l.id !== label.id))
|
||||
showMessage('success', 'Label deleted!')
|
||||
} catch {
|
||||
@@ -2383,7 +2518,7 @@ export function Settings() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await api.saveNameLabel(user.$id, user.email, editingNameLabel)
|
||||
const saved = await api.saveNameLabel(editingNameLabel)
|
||||
if (saved.data) {
|
||||
if (editingNameLabel.id) {
|
||||
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
|
||||
@@ -2466,7 +2601,7 @@ export function Settings() {
|
||||
onDisconnect={async (accountId) => {
|
||||
if (!user?.$id) return
|
||||
try {
|
||||
const result = await api.disconnectEmailAccount(accountId, user.$id)
|
||||
const result = await api.disconnectEmailAccount(accountId)
|
||||
if (result.data) {
|
||||
setAccounts(accounts.filter(a => a.id !== accountId))
|
||||
showMessage('success', 'Account disconnected')
|
||||
@@ -2479,7 +2614,7 @@ export function Settings() {
|
||||
if (!user?.$id) return
|
||||
if (!confirm('Are you absolutely sure? This cannot be undone.')) return
|
||||
try {
|
||||
const result = await api.deleteAccount(user.$id)
|
||||
const result = await api.deleteAccount()
|
||||
if (result.data) {
|
||||
showMessage('success', 'Account deleted. Redirecting...')
|
||||
setTimeout(() => {
|
||||
@@ -2503,30 +2638,63 @@ export function Settings() {
|
||||
<CardDescription>Manage your MailFlow subscription</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center">
|
||||
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{subscription?.plan || 'Trial'}</h3>
|
||||
<Badge variant={subscription?.status === 'active' ? 'success' : 'warning'}>
|
||||
{subscription?.status === 'active' ? 'Active' : 'Trial'}
|
||||
</Badge>
|
||||
</div>
|
||||
{subscription?.currentPeriodEnd && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 py-6">
|
||||
Loading subscription…
|
||||
</p>
|
||||
) : !subscription ? (
|
||||
<div className="space-y-3 py-2">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Subscription status could not be loaded. Make sure you are signed in and the API is running.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const r = await api.getSubscriptionStatus()
|
||||
if (r.data) {
|
||||
setSubscription(r.data)
|
||||
showMessage('success', 'Subscription loaded')
|
||||
} else {
|
||||
showMessage('error', r.error?.message || 'Failed to load subscription')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleManageSubscription}>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center shrink-0">
|
||||
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">
|
||||
{subscriptionTitle(subscription)}
|
||||
</h3>
|
||||
{(() => {
|
||||
const b = subscriptionBadge(subscription)
|
||||
return b.label ? (
|
||||
<Badge variant={b.variant}>{b.label}</Badge>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
{subscription.currentPeriodEnd && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Next billing:{' '}
|
||||
{new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="shrink-0" onClick={handleManageSubscription}>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2556,8 +2724,8 @@ export function Settings() {
|
||||
<span className="text-slate-500 dark:text-slate-400">/month</span>
|
||||
</div>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
{plan.features.map((feature, fi) => (
|
||||
<li key={`${plan.id}-${fi}`} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<Check className="w-4 h-4 text-green-500 dark:text-green-400" />
|
||||
{feature}
|
||||
</li>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function Setup() {
|
||||
if (user?.$id) {
|
||||
const loadOnboarding = async () => {
|
||||
try {
|
||||
const stateRes = await api.getOnboardingStatus(user.$id)
|
||||
const stateRes = await api.getOnboardingStatus()
|
||||
if (stateRes.data) {
|
||||
setOnboardingState(stateRes.data)
|
||||
|
||||
@@ -89,7 +89,7 @@ export function Setup() {
|
||||
if (isFromCheckout && user?.$id) {
|
||||
const checkAccounts = async () => {
|
||||
try {
|
||||
const accountsRes = await api.getEmailAccounts(user.$id)
|
||||
const accountsRes = await api.getEmailAccounts()
|
||||
if (accountsRes.data && accountsRes.data.length > 0) {
|
||||
// User already has accounts connected - redirect to dashboard
|
||||
navigate('/dashboard?subscription=success&ready=true')
|
||||
@@ -118,16 +118,16 @@ export function Setup() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await api.getOAuthUrl('gmail', user.$id)
|
||||
const response = await api.getOAuthUrl('gmail')
|
||||
if (response.data?.url) {
|
||||
// Track onboarding step before redirect
|
||||
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
|
||||
await api.updateOnboardingStep('connect', ['connect'])
|
||||
window.location.href = response.data.url
|
||||
} else {
|
||||
setConnectedProvider('gmail')
|
||||
setConnectedEmail(user.email)
|
||||
setCurrentStep('complete')
|
||||
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
|
||||
await api.updateOnboardingStep('see_results', ['connect'])
|
||||
trackOnboardingStep(user.$id, 'first_rule')
|
||||
trackProviderConnected(user.$id, 'gmail')
|
||||
}
|
||||
@@ -144,16 +144,16 @@ export function Setup() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await api.getOAuthUrl('outlook', user.$id)
|
||||
const response = await api.getOAuthUrl('outlook')
|
||||
if (response.data?.url) {
|
||||
// Track onboarding step before redirect
|
||||
await api.updateOnboardingStep(user.$id, 'connect', ['connect'])
|
||||
await api.updateOnboardingStep('connect', ['connect'])
|
||||
window.location.href = response.data.url
|
||||
} else {
|
||||
setConnectedProvider('outlook')
|
||||
setConnectedEmail(user.email)
|
||||
setCurrentStep('complete')
|
||||
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
|
||||
await api.updateOnboardingStep('see_results', ['connect'])
|
||||
}
|
||||
} catch {
|
||||
setError('Outlook connection failed. Please try again.')
|
||||
@@ -168,12 +168,12 @@ export function Setup() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await api.connectDemoAccount(user.$id)
|
||||
const response = await api.connectDemoAccount()
|
||||
if (response.data) {
|
||||
setConnectedProvider('demo')
|
||||
setConnectedEmail(response.data.email)
|
||||
setCurrentStep('complete')
|
||||
await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
|
||||
await api.updateOnboardingStep('see_results', ['connect'])
|
||||
trackOnboardingStep(user.$id, 'first_rule')
|
||||
trackDemoUsed(user.$id)
|
||||
}
|
||||
@@ -202,7 +202,7 @@ export function Setup() {
|
||||
const completedSteps = onboardingState?.completedSteps || []
|
||||
if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) {
|
||||
const newCompleted = [...completedSteps, stepMap[currentStep]]
|
||||
await api.updateOnboardingStep(user.$id, onboardingStep, newCompleted)
|
||||
await api.updateOnboardingStep(onboardingStep, newCompleted)
|
||||
setOnboardingState({
|
||||
onboarding_step: onboardingStep,
|
||||
completedSteps: newCompleted,
|
||||
@@ -227,7 +227,7 @@ export function Setup() {
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.saveUserPreferences(user.$id, {
|
||||
await api.saveUserPreferences({
|
||||
vipSenders: [],
|
||||
blockedSenders: [],
|
||||
customRules: [],
|
||||
@@ -235,7 +235,7 @@ export function Setup() {
|
||||
})
|
||||
|
||||
// Mark onboarding as completed
|
||||
await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'see_results'])
|
||||
await api.updateOnboardingStep('completed', ['connect', 'see_results'])
|
||||
} catch (err) {
|
||||
console.error('Failed to save preferences:', err)
|
||||
} finally {
|
||||
@@ -248,7 +248,7 @@ export function Setup() {
|
||||
if (!user?.$id) return
|
||||
|
||||
try {
|
||||
await api.skipOnboarding(user.$id)
|
||||
await api.skipOnboarding()
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to skip onboarding:', err)
|
||||
|
||||
@@ -1,27 +1,53 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, '')
|
||||
const appwriteDevOrigin = (env.APPWRITE_DEV_ORIGIN || '').replace(/\/$/, '')
|
||||
// 127.0.0.1 avoids Windows localhost → IPv6 (::1) vs backend listening on IPv4-only
|
||||
const apiDevTarget = (env.VITE_DEV_API_ORIGIN || 'http://127.0.0.1:3000').replace(
|
||||
/\/$/,
|
||||
''
|
||||
)
|
||||
|
||||
const proxy: Record<
|
||||
string,
|
||||
{ target: string; changeOrigin: boolean; secure?: boolean }
|
||||
> = {
|
||||
'/api': {
|
||||
target: apiDevTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/stripe': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
'/stripe': {
|
||||
target: apiDevTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Dev: Browser → localhost:5173/v1/* → Appwrite (umgeht CORS, wenn die Console nur z. B. webklar.com erlaubt)
|
||||
if (mode === 'development' && appwriteDevOrigin) {
|
||||
proxy['/v1'] = {
|
||||
target: appwriteDevOrigin,
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// Wenn 5173 schon belegt ist, nicht still auf einen anderen Port wechseln — sonst öffnet man oft noch die alte URL und bekommt für /api 404.
|
||||
strictPort: true,
|
||||
proxy,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user