diff --git a/app/routes/ai-insights.module.css b/app/routes/ai-insights.module.css index a35143f..cd66fcd 100644 --- a/app/routes/ai-insights.module.css +++ b/app/routes/ai-insights.module.css @@ -3,3 +3,24 @@ max-width: var(--content-max-width); margin: 0 auto; } + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--space-4); + color: var(--color-text-muted); + font-size: 1rem; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/app/routes/ai-insights.tsx b/app/routes/ai-insights.tsx index 5d5602c..f1233d5 100644 --- a/app/routes/ai-insights.tsx +++ b/app/routes/ai-insights.tsx @@ -1,15 +1,15 @@ -import { useState, useEffect } from "react"; -import { useLoaderData, useFetcher } from "react-router"; +import { useState, useEffect, Suspense } from "react"; +import { useLoaderData, useFetcher, Await } from "react-router"; import type { Route } from "./+types/ai-insights"; import { PrismaClient } from "@prisma/client"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; - +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import styles from "./ai-insights.module.css"; import { AiInsightsHeader } from "~/blocks/ai-insights/ai-insights-header"; import { PlanGateMessage } from "~/blocks/ai-insights/plan-gate-message"; import { BatchAnalysisStatus } from "~/blocks/ai-insights/batch-analysis-status"; import { ItemAnalysisCards } from "~/blocks/ai-insights/item-analysis-cards"; import { DetailedAnalysisModal } from "~/blocks/ai-insights/detailed-analysis-modal"; +import { IconLoader2 } from "@tabler/icons-react"; export interface AiInsightItem { id: string; @@ -27,29 +27,31 @@ const prisma = new PrismaClient(); export async function loader({ request }: Route.LoaderArgs) { try { const { supabase } = getSupabaseServerClient(request); - const { data: { user } } = await supabase.auth.getUser(); + const { data: { user } } = await getUserFromRequest(request, supabase); if (!user) { - return { inventory: [] }; + return { deferredData: Promise.resolve([] as any[]) }; } - const inventory = await prisma.inventoryItem.findMany({ + const deferredData = prisma.inventoryItem.findMany({ where: { userId: user.id }, include: { priceHistory: true } }); - return { inventory }; + return { deferredData }; } catch (error) { console.error("Failed to load inventory for insights:", error); - return { inventory: [] }; + return { deferredData: Promise.resolve([] as any[]) }; } } -export default function AiInsightsPage() { - const { inventory } = useLoaderData() as { inventory: any[] }; - +interface PageContentProps { + inventory: any[]; +} + +function AiInsightsPageContent({ inventory }: PageContentProps) { const fetcher = useFetcher(); const [insights, setInsights] = useState([]); const [analyzing, setAnalyzing] = useState(false); @@ -72,7 +74,7 @@ export default function AiInsightsPage() { }; return ( -
+ <> {!isPro && } {isPro && (analyzing || fetcher.state === "submitting") && ( @@ -88,6 +90,27 @@ export default function AiInsightsPage() { onClose={() => setSelectedItem(null)} /> )} + + ); +} + +export default function AiInsightsPage() { + const { deferredData } = useLoaderData(); + + return ( +
+ + + Loading insights data... +
+ } + > + + {(inventory) => } + +
); } \ No newline at end of file diff --git a/app/routes/app-layout.tsx b/app/routes/app-layout.tsx index 786b807..305e65f 100644 --- a/app/routes/app-layout.tsx +++ b/app/routes/app-layout.tsx @@ -1,5 +1,5 @@ import { Outlet, redirect, useLoaderData } from "react-router"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import { PrismaClient } from "@prisma/client"; import type { Route } from "./+types/app-layout"; import { AppSidebar } from "~/blocks/__global/app-sidebar"; @@ -13,13 +13,17 @@ export function headers(_: Route.HeadersArgs) { }; } +export function shouldRevalidate({ formMethod }: { formMethod: string }) { + return formMethod !== "GET"; +} + const prisma = new PrismaClient(); export async function loader({ request }: Route.LoaderArgs) { const { supabase, headers } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); if (!user) { throw redirect("/auth/login", { headers }); diff --git a/app/routes/auth.callback.tsx b/app/routes/auth.callback.tsx index 4921752..c07b915 100644 --- a/app/routes/auth.callback.tsx +++ b/app/routes/auth.callback.tsx @@ -1,6 +1,6 @@ import { redirect } from "react-router"; import type { Route } from "./+types/auth.callback"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, authSessionStorage } from "~/utils/supabase.server"; export async function loader({ request }: Route.LoaderArgs) { const { supabase, headers } = getSupabaseServerClient(request); @@ -17,5 +17,13 @@ export async function loader({ request }: Route.LoaderArgs) { return redirect("/auth/login"); } + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + const session = await authSessionStorage.getSession(request.headers.get("Cookie")); + session.set("userId", user.id); + session.set("email", user.email); + headers.append("Set-Cookie", await authSessionStorage.commitSession(session)); + } + return redirect("/app/dashboard", { headers }); } \ No newline at end of file diff --git a/app/routes/auth.logout.ts b/app/routes/auth.logout.ts index 6c51a5b..6602abb 100644 --- a/app/routes/auth.logout.ts +++ b/app/routes/auth.logout.ts @@ -1,10 +1,14 @@ import { redirect } from "react-router"; import type { Route } from "./+types/auth.logout"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, authSessionStorage } from "~/utils/supabase.server"; export async function action({ request }: Route.ActionArgs) { const { supabase, headers } = getSupabaseServerClient(request); await supabase.auth.signOut(); + + const session = await authSessionStorage.getSession(request.headers.get("Cookie")); + headers.append("Set-Cookie", await authSessionStorage.destroySession(session)); + return redirect("/auth/login", { headers }); } diff --git a/app/routes/dashboard.module.css b/app/routes/dashboard.module.css index a35143f..cd66fcd 100644 --- a/app/routes/dashboard.module.css +++ b/app/routes/dashboard.module.css @@ -3,3 +3,24 @@ max-width: var(--content-max-width); margin: 0 auto; } + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--space-4); + color: var(--color-text-muted); + font-size: 1rem; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index 1f6db40..984dfcf 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -1,6 +1,7 @@ -import { useLoaderData } from "react-router"; +import { Suspense } from "react"; +import { useLoaderData, Await } from "react-router"; import type { Route } from "./+types/dashboard"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import { PrismaClient, Prisma } from "@prisma/client"; import styles from "./dashboard.module.css"; import { DashboardHeader } from "~/blocks/dashboard/dashboard-header"; @@ -11,9 +12,9 @@ import { SalesByMarketplacePie } from "~/blocks/dashboard/sales-by-marketplace-p import { TopSellingItemsTable } from "~/blocks/dashboard/top-selling-items-table"; import { RecentSales } from "~/blocks/dashboard/recent-sales"; import { ExpenseCategoriesChart } from "~/blocks/dashboard/expense-categories-chart"; - import { AIInsightsPanel } from "~/blocks/dashboard/ai-insights-panel"; import { CACHE_PRIVATE_NO_STORE } from "~/utils/cache-headers"; +import { IconLoader2 } from "@tabler/icons-react"; export function headers(_: Route.HeadersArgs) { return { @@ -27,10 +28,16 @@ export async function loader({ request }: Route.LoaderArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); if (!user) { - return { inventoryStats: null, salesData: [], expensesData: [] }; + return { + deferredData: Promise.resolve({ + inventoryStats: { _count: 0, _sum: { purchasePrice: 0 } }, + salesData: [] as any[], + expensesData: [] as any[], + }), + }; } const url = new URL(request.url); @@ -90,68 +97,90 @@ export async function loader({ request }: Route.LoaderArgs) { : {}), }; - const [inventoryStats, salesData, expensesData] = await Promise.all([ - prisma.inventoryItem.aggregate({ - where: { userId: user.id, status: "IN_STOCK" }, - _sum: { purchasePrice: true }, - _count: true, - }), - prisma.sale.findMany({ - where: saleWhereClause, - include: { expenses: true, inventoryItem: true }, - orderBy: { saleDate: "desc" }, - }), - prisma.expense.findMany({ - where: expenseWhereClause, - }), - ]); - - const serializedStats = { - _count: inventoryStats?._count || 0, - _sum: { purchasePrice: Number(inventoryStats?._sum?.purchasePrice || 0) }, - }; - - const serializedSales = salesData.map((s) => ({ - ...s, - salePrice: Number(s.salePrice), - platformFee: Number(s.platformFee), - shippingCost: Number(s.shippingCost), - inventoryItem: { - ...s.inventoryItem, - purchasePrice: Number(s.inventoryItem.purchasePrice), - }, + const statsPromise = prisma.inventoryItem.aggregate({ + where: { userId: user.id, status: "IN_STOCK" }, + _sum: { purchasePrice: true }, + _count: true, + }).then((stats) => ({ + _count: stats?._count || 0, + _sum: { purchasePrice: Number(stats?._sum?.purchasePrice || 0) }, })); - const serializedExpenses = expensesData.map((e) => ({ - ...e, - amount: Number(e.amount), - })); + const salesPromise = prisma.sale.findMany({ + where: saleWhereClause, + include: { expenses: true, inventoryItem: true }, + orderBy: { saleDate: "desc" }, + }).then((sales) => + sales.map((s) => ({ + ...s, + salePrice: Number(s.salePrice), + platformFee: Number(s.platformFee), + shippingCost: Number(s.shippingCost), + inventoryItem: { + ...s.inventoryItem, + purchasePrice: Number(s.inventoryItem.purchasePrice), + }, + })) + ); - return { inventoryStats: serializedStats, salesData: serializedSales, expensesData: serializedExpenses }; + const expensesPromise = prisma.expense.findMany({ + where: expenseWhereClause, + }).then((expenses) => + expenses.map((e) => ({ + ...e, + amount: Number(e.amount), + })) + ); + + const deferredData = Promise.all([statsPromise, salesPromise, expensesPromise]).then( + ([inventoryStats, salesData, expensesData]) => ({ + inventoryStats, + salesData, + expensesData, + }) + ); + + return { deferredData }; } export default function DashboardPage() { - const { inventoryStats, salesData, expensesData } = useLoaderData(); + const { deferredData } = useLoaderData(); return (
- - -
+ + Loading dashboard data... +
+ } > - - - -
- - + + {({ inventoryStats, salesData, expensesData }) => ( + <> + + +
+ + + +
+ + + + )} +
+ ); } + diff --git a/app/routes/expenses-tracker.module.css b/app/routes/expenses-tracker.module.css index a35143f..cd66fcd 100644 --- a/app/routes/expenses-tracker.module.css +++ b/app/routes/expenses-tracker.module.css @@ -3,3 +3,24 @@ max-width: var(--content-max-width); margin: 0 auto; } + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--space-4); + color: var(--color-text-muted); + font-size: 1rem; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/app/routes/expenses-tracker.tsx b/app/routes/expenses-tracker.tsx index 593cfa1..b4d25d6 100644 --- a/app/routes/expenses-tracker.tsx +++ b/app/routes/expenses-tracker.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from "react"; -import { useLoaderData, useActionData } from "react-router"; +import { useState, useEffect, Suspense } from "react"; +import { useLoaderData, useActionData, Await } from "react-router"; import type { Route } from "./+types/expenses-tracker"; import { toast } from "sonner"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import { PrismaClient } from "@prisma/client"; import styles from "./expenses-tracker.module.css"; import { ExpensesHeader } from "~/blocks/expenses-tracker/expenses-header"; @@ -12,6 +12,7 @@ import { ExpensesSummary } from "~/blocks/expenses-tracker/expenses-summary"; import { AddExpenseModal } from "~/blocks/expenses-tracker/add-expense-modal"; import { Pagination } from "~/blocks/__global/pagination"; import { CACHE_PRIVATE_NO_STORE } from "~/utils/cache-headers"; +import { IconLoader2 } from "@tabler/icons-react"; export function headers(_: Route.HeadersArgs) { return { @@ -25,45 +26,60 @@ export async function loader({ request }: Route.LoaderArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); - - if (!user) return { expenses: [], recurring: [], totalPages: 0, oneTimeTotal: 0 }; + } = await getUserFromRequest(request, supabase); + + if (!user) return { + deferredData: Promise.resolve({ + expenses: [] as any[], + recurring: [] as any[], + totalPages: 0, + oneTimeTotal: 0, + }), + }; const url = new URL(request.url); const page = Math.max(1, Number(url.searchParams.get("page")) || 1); const pageSize = Number(url.searchParams.get("pageSize")) || 10; - const [totalExpenses, expenses, recurring, sumResult] = await Promise.all([ - prisma.expense.count({ where: { userId: user.id } }), - prisma.expense.findMany({ - where: { userId: user.id }, - orderBy: { date: "desc" }, - skip: (page - 1) * pageSize, - take: pageSize, - }), - prisma.recurringExpense.findMany({ - where: { userId: user.id }, - orderBy: { createdAt: "desc" }, - }), - prisma.expense.aggregate({ - where: { userId: user.id }, - _sum: { amount: true }, - }), - ]); + const countPromise = prisma.expense.count({ where: { userId: user.id } }); + const expensesPromise = prisma.expense.findMany({ + where: { userId: user.id }, + orderBy: { date: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + }).then((expenses) => + expenses.map((e) => ({ ...e, amount: Number(e.amount) })) + ); - return { - expenses: expenses.map(e => ({ ...e, amount: Number(e.amount) })), - recurring: recurring.map(r => ({ ...r, amount: Number(r.amount) })), - totalPages: Math.ceil(totalExpenses / pageSize), - oneTimeTotal: Number(sumResult._sum.amount || 0), - }; + const recurringPromise = prisma.recurringExpense.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + }).then((recurring) => + recurring.map((r) => ({ ...r, amount: Number(r.amount) })) + ); + + const aggregatePromise = prisma.expense.aggregate({ + where: { userId: user.id }, + _sum: { amount: true }, + }); + + const deferredData = Promise.all([countPromise, expensesPromise, recurringPromise, aggregatePromise]).then( + ([totalExpenses, formattedExpenses, formattedRecurring, sumResult]) => ({ + expenses: formattedExpenses, + recurring: formattedRecurring, + totalPages: Math.ceil(totalExpenses / pageSize), + oneTimeTotal: Number(sumResult._sum.amount || 0), + }) + ); + + return { deferredData }; } export async function action({ request }: Route.ActionArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); if (!user) return new Response("Unauthorized", { status: 401 }); @@ -145,7 +161,7 @@ export async function action({ request }: Route.ActionArgs) { } export default function ExpensesTrackerPage() { - const { expenses, recurring, totalPages, oneTimeTotal } = useLoaderData(); + const { deferredData } = useLoaderData(); const actionData = useActionData(); const [showAddExpense, setShowAddExpense] = useState(false); const [editingExpense, setEditingExpense] = useState(null); @@ -169,13 +185,28 @@ export default function ExpensesTrackerPage() { return (
setShowAddExpense(true)} /> - - - setEditingExpense(expense)} - /> - + + + Loading expenses data... +
+ } + > + + {({ expenses, recurring, totalPages, oneTimeTotal }) => ( + <> + + + setEditingExpense(expense)} + /> + + + )} + + {showAddExpense && setShowAddExpense(false)} />} {editingExpense && ( + sales.map((s) => ({ + ...s, + salePrice: Number(s.salePrice), + platformFee: Number(s.platformFee), + shippingCost: Number(s.shippingCost), + inventoryItem: { + ...s.inventoryItem, + purchasePrice: Number(s.inventoryItem.purchasePrice), + }, + })) + ); - const serializedSales = sales.map((s) => ({ - ...s, - salePrice: Number(s.salePrice), - platformFee: Number(s.platformFee), - shippingCost: Number(s.shippingCost), - inventoryItem: { - ...s.inventoryItem, - purchasePrice: Number(s.inventoryItem.purchasePrice), - }, - })); + const expensesPromise = prisma.expense.findMany({ + where: { userId: user.id }, + orderBy: { date: "asc" }, + }).then((expenses) => + expenses.map((e) => ({ + ...e, + amount: Number(e.amount), + })) + ); - const serializedExpenses = expenses.map((e) => ({ - ...e, - amount: Number(e.amount), + const deferredData = Promise.all([salesPromise, expensesPromise]).then(([serializedSales, serializedExpenses]) => ({ + sales: serializedSales, + expenses: serializedExpenses, })); - return { sales: serializedSales, expenses: serializedExpenses }; + return { deferredData }; } export default function IncomeStatementPage() { - const { sales, expenses } = useLoaderData(); + const { deferredData } = useLoaderData(); return (
- -
+ + Loading statement data... +
+ } > - - -
- + + {({ sales, expenses }) => ( + <> + +
+ + +
+ + + )} +
+ ); diff --git a/app/routes/inventory-management.module.css b/app/routes/inventory-management.module.css index a35143f..cd66fcd 100644 --- a/app/routes/inventory-management.module.css +++ b/app/routes/inventory-management.module.css @@ -3,3 +3,24 @@ max-width: var(--content-max-width); margin: 0 auto; } + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--space-4); + color: var(--color-text-muted); + font-size: 1rem; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/app/routes/inventory-management.tsx b/app/routes/inventory-management.tsx index df68340..2d964ca 100644 --- a/app/routes/inventory-management.tsx +++ b/app/routes/inventory-management.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from "react"; -import { useLoaderData, useActionData, useSearchParams } from "react-router"; +import { useState, useEffect, Suspense } from "react"; +import { useLoaderData, useActionData, useSearchParams, Await } from "react-router"; import type { Route } from "./+types/inventory-management"; import { toast } from "sonner"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import { Prisma, PrismaClient, type Currency, type ItemCondition } from "@prisma/client"; import styles from "./inventory-management.module.css"; +import { IconLoader2 } from "@tabler/icons-react"; import { InventoryHeader } from "~/blocks/inventory-management/inventory-header"; import { InventoryTable } from "~/blocks/inventory-management/inventory-table"; import { BulkActionsBar } from "~/blocks/inventory-management/bulk-actions-bar"; @@ -125,9 +126,13 @@ export async function loader({ request }: Route.LoaderArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); - if (!user) return { items: [], totalPages: 0 }; + if (!user) { + return { + deferredData: Promise.resolve({ items: [] as any[], totalPages: 0 }), + }; + } const url = new URL(request.url); const page = Math.max(1, Number(url.searchParams.get("page")) || 1); @@ -147,36 +152,39 @@ export async function loader({ request }: Route.LoaderArgs) { : {}), }; - const [totalItems, items] = await Promise.all([ - prisma.inventoryItem.count({ where: whereClause }), - prisma.inventoryItem.findMany({ - where: whereClause, - orderBy: { createdAt: "desc" }, - skip: (page - 1) * pageSize, - take: pageSize, - include: { - priceHistory: { - orderBy: { fetchedAt: "desc" }, - take: 1, - }, + const countPromise = prisma.inventoryItem.count({ where: whereClause }); + const itemsPromise = prisma.inventoryItem.findMany({ + where: whereClause, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + priceHistory: { + orderBy: { fetchedAt: "desc" }, + take: 1, }, - }), - ]); + }, + }).then((items) => + items.map((item) => ({ + ...item, + purchasePrice: Number(item.purchasePrice), + marketValue: item.priceHistory[0]?.askPrice ? Number(item.priceHistory[0].askPrice) : null, + })) + ); - const formattedItems = items.map((item) => ({ - ...item, - purchasePrice: Number(item.purchasePrice), - marketValue: item.priceHistory[0]?.askPrice ? Number(item.priceHistory[0].askPrice) : null, + const deferredData = Promise.all([countPromise, itemsPromise]).then(([totalItems, formattedItems]) => ({ + items: formattedItems, + totalPages: Math.ceil(totalItems / pageSize), })); - return { items: formattedItems, totalPages: Math.ceil(totalItems / pageSize) }; + return { deferredData }; } export async function action({ request }: Route.ActionArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); if (!user) return new Response("Unauthorized", { status: 401 }); const formData = await request.formData(); @@ -341,7 +349,7 @@ export async function action({ request }: Route.ActionArgs) { } export default function InventoryManagementPage() { - const { items, totalPages } = useLoaderData(); + const { deferredData } = useLoaderData(); const actionData = useActionData(); const [showAddItem, setShowAddItem] = useState(false); const [showImport, setShowImport] = useState(false); @@ -401,17 +409,32 @@ export default function InventoryManagementPage() { searchQuery={searchQuery} onSearch={setSearchQuery} /> - {selected.length > 0 && ( - setSelected([])} selectedIds={selected} items={items} /> - )} - setDuplicatingItem({ ...item, id: undefined, sku: "" })} - /> - + + + Loading inventory items... + + } + > + + {({ items, totalPages }) => ( + <> + {selected.length > 0 && ( + setSelected([])} selectedIds={selected} items={items} /> + )} + setDuplicatingItem({ ...item, id: undefined, sku: "" })} + /> + + + )} + + {showAddItem && setShowAddItem(false)} />} {editingItem && setEditingItem(null)} />} {duplicatingItem && setDuplicatingItem(null)} />} diff --git a/app/routes/login-page.tsx b/app/routes/login-page.tsx index 2abedaa..694104e 100644 --- a/app/routes/login-page.tsx +++ b/app/routes/login-page.tsx @@ -1,6 +1,6 @@ import { redirect } from "react-router"; import type { Route } from "./+types/login-page"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, authSessionStorage } from "~/utils/supabase.server"; import styles from "./login-page.module.css"; import { LoginForm } from "~/blocks/login-page/login-form"; import { OAuthOptions } from "~/blocks/login-page/o-auth-options"; @@ -20,11 +20,18 @@ export async function action({ request }: Route.ActionArgs) { const email = formData.get("email") as string; const password = formData.get("password") as string; - const { error } = await supabase.auth.signInWithPassword({ email, password }); + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); if (error) { return { error: error.message }; } + + if (data.user) { + const session = await authSessionStorage.getSession(request.headers.get("Cookie")); + session.set("userId", data.user.id); + session.set("email", data.user.email); + headers.append("Set-Cookie", await authSessionStorage.commitSession(session)); + } return redirect("/app/dashboard", { headers }); } diff --git a/app/routes/market-prices.module.css b/app/routes/market-prices.module.css index a35143f..cd66fcd 100644 --- a/app/routes/market-prices.module.css +++ b/app/routes/market-prices.module.css @@ -3,3 +3,24 @@ max-width: var(--content-max-width); margin: 0 auto; } + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--space-4); + color: var(--color-text-muted); + font-size: 1rem; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/app/routes/market-prices.tsx b/app/routes/market-prices.tsx index 390d09f..95ac0cb 100644 --- a/app/routes/market-prices.tsx +++ b/app/routes/market-prices.tsx @@ -1,13 +1,15 @@ +import { Suspense } from "react"; +import { useLoaderData, Await } from "react-router"; import type { Route } from "./+types/market-prices"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import { PrismaClient } from "@prisma/client"; -import { useLoaderData } from "react-router"; import styles from "./market-prices.module.css"; import { MarketPricesHeader } from "~/blocks/market-prices/market-prices-header"; import { PriceComparisonTable } from "~/blocks/market-prices/price-comparison-table"; import { PriceUpdateStatus } from "~/blocks/market-prices/price-update-status"; import { PriceAlertsQuickAdd } from "~/blocks/market-prices/price-alerts-quick-add"; import { CACHE_PRIVATE_SHARED_DATA } from "~/utils/cache-headers"; +import { IconLoader2 } from "@tabler/icons-react"; export function headers(_: Route.HeadersArgs) { return { @@ -21,46 +23,61 @@ export async function loader({ request }: Route.LoaderArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); - if (!user) return { prices: [] }; + if (!user) { + return { + deferredData: Promise.resolve([] as any[]), + }; + } - // Fetch user's inventory to match SKUs against market prices - const userItems = await prisma.inventoryItem.findMany({ + // Fetch user's inventory to match SKUs against market prices (deferred) + const deferredData = prisma.inventoryItem.findMany({ where: { userId: user.id }, select: { sku: true, size: true, name: true, purchasePrice: true }, - }); + }).then(async (userItems) => { + const skus = Array.from(new Set(userItems.map((i) => i.sku))); - const skus = Array.from(new Set(userItems.map((i) => i.sku))); + const prices = await prisma.marketPrice.findMany({ + where: { sku: { in: skus } }, + orderBy: { fetchedAt: "desc" }, + }); - const prices = await prisma.marketPrice.findMany({ - where: { sku: { in: skus } }, - orderBy: { fetchedAt: "desc" }, - }); - - // Map product names to prices based on SKU - const mappedPrices = prices.map((p) => { - const matchingItem = userItems.find((i) => i.sku === p.sku && i.size === p.size); - return { - ...p, - askPrice: p.askPrice ? Number(p.askPrice) : null, - bidPrice: p.bidPrice ? Number(p.bidPrice) : null, - lastSold: p.lastSold ? Number(p.lastSold) : null, - productName: matchingItem ? matchingItem.name : "Unknown Product", - }; + // Map product names to prices based on SKU + return prices.map((p) => { + const matchingItem = userItems.find((i) => i.sku === p.sku && i.size === p.size); + return { + ...p, + askPrice: p.askPrice ? Number(p.askPrice) : null, + bidPrice: p.bidPrice ? Number(p.bidPrice) : null, + lastSold: p.lastSold ? Number(p.lastSold) : null, + productName: matchingItem ? matchingItem.name : "Unknown Product", + }; + }); }); - return { prices: mappedPrices }; + return { deferredData }; } export default function MarketPricesPage() { - const { prices } = useLoaderData(); + const { deferredData } = useLoaderData(); return (
- + + + Loading market prices... +
+ } + > + + {(prices) => } + + ); diff --git a/app/routes/price-alerts.module.css b/app/routes/price-alerts.module.css index a35143f..cd66fcd 100644 --- a/app/routes/price-alerts.module.css +++ b/app/routes/price-alerts.module.css @@ -3,3 +3,24 @@ max-width: var(--content-max-width); margin: 0 auto; } + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--space-4); + color: var(--color-text-muted); + font-size: 1rem; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/app/routes/price-alerts.tsx b/app/routes/price-alerts.tsx index d3b7df4..c87f44d 100644 --- a/app/routes/price-alerts.tsx +++ b/app/routes/price-alerts.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; +import { useState, Suspense } from "react"; import type { Route } from "./+types/price-alerts"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import { PrismaClient, Marketplace, AlertDirection, NotificationChannel } from "@prisma/client"; -import { useLoaderData, useSubmit } from "react-router"; +import { useLoaderData, useSubmit, Await } from "react-router"; import styles from "./price-alerts.module.css"; import { AlertsHeader } from "~/blocks/price-alerts/alerts-header"; import { PlanLimitWarning } from "~/blocks/price-alerts/plan-limit-warning"; @@ -10,6 +10,7 @@ import { CreateAlertForm } from "~/blocks/price-alerts/create-alert-form"; import { ActiveAlertsTable } from "~/blocks/price-alerts/active-alerts-table"; import { AlertHistory } from "~/blocks/price-alerts/alert-history"; import { CACHE_PRIVATE_NO_STORE } from "~/utils/cache-headers"; +import { IconLoader2 } from "@tabler/icons-react"; export function headers(_: Route.HeadersArgs) { return { @@ -23,23 +24,27 @@ export async function loader({ request }: Route.LoaderArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); - if (!user) return { alerts: [] }; + if (!user) return { + deferredData: Promise.resolve({ alerts: [] as any[] }), + }; - const alerts = await prisma.priceAlert.findMany({ + const deferredData = prisma.priceAlert.findMany({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, - }); + }).then((alerts) => ({ + alerts: alerts.map((a) => ({ ...a, targetPrice: Number(a.targetPrice) })), + })); - return { alerts: alerts.map((a) => ({ ...a, targetPrice: Number(a.targetPrice) })) }; + return { deferredData }; } export async function action({ request }: Route.ActionArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); if (!user) return new Response("Unauthorized", { status: 401 }); const formData = await request.formData(); @@ -83,15 +88,30 @@ export async function action({ request }: Route.ActionArgs) { export default function PriceAlertsPage() { const [showCreate, setShowCreate] = useState(false); - const { alerts } = useLoaderData(); + const { deferredData } = useLoaderData(); return (
setShowCreate(true)} /> {showCreate && setShowCreate(false)} />} - - a.triggeredAt)} /> + + + Loading price alerts... +
+ } + > + + {({ alerts }) => ( + <> + + a.triggeredAt)} /> + + )} + + ); } diff --git a/app/routes/sales-log.module.css b/app/routes/sales-log.module.css index a35143f..cd66fcd 100644 --- a/app/routes/sales-log.module.css +++ b/app/routes/sales-log.module.css @@ -3,3 +3,24 @@ max-width: var(--content-max-width); margin: 0 auto; } + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--space-4); + color: var(--color-text-muted); + font-size: 1rem; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/app/routes/sales-log.tsx b/app/routes/sales-log.tsx index 2e15719..aa39f3a 100644 --- a/app/routes/sales-log.tsx +++ b/app/routes/sales-log.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect } from "react"; -import { useLoaderData, useActionData, useSearchParams } from "react-router"; +import { useState, useEffect, Suspense } from "react"; +import { useLoaderData, useActionData, useSearchParams, Await } from "react-router"; import type { Route } from "./+types/sales-log"; import { toast } from "sonner"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import { PrismaClient } from "@prisma/client"; import styles from "./sales-log.module.css"; import { SalesHeader } from "~/blocks/sales-log/sales-header"; @@ -11,6 +11,7 @@ import { SalesTable } from "~/blocks/sales-log/sales-table"; import { LogSaleModal } from "~/blocks/sales-log/log-sale-modal"; import type { SortField, SortDirection } from "~/blocks/sales-log/sales-table"; import { CACHE_PRIVATE_NO_STORE } from "~/utils/cache-headers"; +import { IconLoader2 } from "@tabler/icons-react"; const prisma = new PrismaClient(); @@ -39,20 +40,22 @@ export async function loader({ request }: Route.LoaderArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); if (!user) { - return { - sales: [], - totalCount: 0, // Add this - totalPages: 0, - summary: { totalSalesCount: 0, totalRevenue: 0, totalProfit: 0 }, - sortField: 'saleDate', - sortDirection: 'desc', - currentPage: 1, - pageSize: 10, - }; -} + return { + deferredData: Promise.resolve({ + sales: [] as any[], + totalCount: 0, + totalPages: 0, + summary: { totalSalesCount: 0, totalRevenue: 0, totalProfit: 0 }, + sortField: 'saleDate' as SortField, + sortDirection: 'desc' as SortDirection, + currentPage: 1, + pageSize: 10, + }), + }; + } // Get URL search params const url = new URL(request.url); @@ -84,71 +87,76 @@ export async function loader({ request }: Route.LoaderArgs) { orderBy = { saleDate: 'desc' }; } - // Fetch all data in parallel - const [totalSales, sales, metricsResult] = await Promise.all([ - // Total count for pagination - prisma.sale.count({ - where: { userId: user.id } - }), - // Paginated sales with sorting - prisma.sale.findMany({ - where: { userId: user.id }, - include: { inventoryItem: true }, - orderBy: orderBy, - skip: skip, - take: pageSize, - }), - // Summary metrics using raw SQL for accurate calculations - prisma.$queryRaw<{ totalRevenue: number; totalProfit: number }[]>` - SELECT - COALESCE(SUM(s."salePrice"), 0) AS "totalRevenue", - COALESCE( - SUM( - s."salePrice" - - i."purchasePrice" - - s."platformFee" - - s."shippingCost" - ), - 0 - ) AS "totalProfit" - FROM "Sale" s - JOIN "InventoryItem" i - ON s."inventoryItemId" = i.id - WHERE s."userId" = ${user.id} - ` - ]); + // Fetch all data in parallel as promises (deferred) + const countPromise = prisma.sale.count({ + where: { userId: user.id } + }); + + const salesPromise = prisma.sale.findMany({ + where: { userId: user.id }, + include: { inventoryItem: true }, + orderBy: orderBy, + skip: skip, + take: pageSize, + }).then((sales) => + sales.map(s => ({ + ...s, + salePrice: Number(s.salePrice), + inventoryItem: { + ...s.inventoryItem, + purchasePrice: Number(s.inventoryItem.purchasePrice), + } + })) + ); + + const rawQueryPromise = prisma.$queryRaw<{ totalRevenue: number; totalProfit: number }[]>` + SELECT + COALESCE(SUM(s."salePrice"), 0) AS "totalRevenue", + COALESCE( + SUM( + s."salePrice" + - i."purchasePrice" + - s."platformFee" + - s."shippingCost" + ), + 0 + ) AS "totalProfit" + FROM "Sale" s + JOIN "InventoryItem" i + ON s."inventoryItemId" = i.id + WHERE s."userId" = ${user.id} + `; - const totalRevenue = Number(metricsResult[0]?.totalRevenue || 0); - const totalProfit = Number(metricsResult[0]?.totalProfit || 0); + const deferredData = Promise.all([countPromise, salesPromise, rawQueryPromise]).then( + ([totalSales, formattedSales, metricsResult]) => { + const totalRevenue = Number(metricsResult[0]?.totalRevenue || 0); + const totalProfit = Number(metricsResult[0]?.totalProfit || 0); - return { - sales: sales.map(s => ({ - ...s, - salePrice: Number(s.salePrice), - inventoryItem: { - ...s.inventoryItem, - purchasePrice: Number(s.inventoryItem.purchasePrice), + return { + sales: formattedSales, + totalCount: totalSales, + totalPages: Math.ceil(totalSales / pageSize), + summary: { + totalSalesCount: totalSales, + totalRevenue, + totalProfit, + }, + sortField: field, + sortDirection: direction, + currentPage: page, + pageSize, + }; } - })), - totalCount: totalSales, // Changed from totalPages - totalPages: Math.ceil(totalSales / pageSize), - summary: { - totalSalesCount: totalSales, - totalRevenue, - totalProfit, - }, - sortField: field, - sortDirection: direction, - currentPage: page, - pageSize, -}; + ); + + return { deferredData }; } export async function action({ request }: Route.ActionArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); if (!user) return new Response("Unauthorized", { status: 401 }); const formData = await request.formData(); @@ -201,7 +209,7 @@ export async function action({ request }: Route.ActionArgs) { } export default function SalesLogPage() { - const { sales, summary, sortField, sortDirection, totalCount, pageSize } = useLoaderData(); + const { deferredData } = useLoaderData(); const actionData = useActionData(); const [searchParams, setSearchParams] = useSearchParams(); const [showLogSale, setShowLogSale] = useState(false); @@ -234,24 +242,33 @@ export default function SalesLogPage() { setSearchParams(params); }; - const handlePageChange = (page: number) => { - const params = new URLSearchParams(searchParams); - params.set('page', page.toString()); - setSearchParams(params); - }; - return (
setShowLogSale(true)} /> - - + + + Loading sales data... +
+ } + > + + {({ sales, summary, sortField, sortDirection, totalCount, pageSize }) => ( + <> + + + + )} + + {showLogSale && setShowLogSale(false)} />} ); diff --git a/app/routes/settings.module.css b/app/routes/settings.module.css index 245b9ed..8396e6d 100644 --- a/app/routes/settings.module.css +++ b/app/routes/settings.module.css @@ -11,3 +11,24 @@ @media (max-width: 768px) { .page { grid-template-columns: 1fr; } } + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--space-4); + color: var(--color-text-muted); + font-size: 1rem; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 6a49d59..10ad0c3 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; -import { useLoaderData } from "react-router"; +import { useState, Suspense } from "react"; +import { useLoaderData, Await } from "react-router"; import type { Route } from "./+types/settings"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, getUserFromRequest } from "~/utils/supabase.server"; import { PrismaClient, Currency, Theme } from "@prisma/client"; import { z } from "zod"; import crypto from "crypto"; @@ -14,6 +14,7 @@ import { BillingSection } from "~/blocks/settings/billing-section"; import { TeamSection } from "~/blocks/settings/team-section"; import { SecuritySection } from "~/blocks/settings/security-section"; import { CACHE_PRIVATE_NO_STORE } from "~/utils/cache-headers"; +import { IconLoader2 } from "@tabler/icons-react"; export function headers(_: Route.HeadersArgs) { return { @@ -27,23 +28,23 @@ export async function loader({ request }: Route.LoaderArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user: authUser }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); - if (!authUser) return { user: null }; + if (!authUser) return { deferredData: Promise.resolve({ user: null }) }; - const dbUser = await prisma.user.findUnique({ + const deferredData = prisma.user.findUnique({ where: { id: authUser.id }, include: { team: { include: { members: true } } }, - }); + }).then((dbUser) => ({ user: dbUser })); - return { user: dbUser }; + return { deferredData }; } export async function action({ request }: Route.ActionArgs) { const { supabase } = getSupabaseServerClient(request); const { data: { user: authUser }, - } = await supabase.auth.getUser(); + } = await getUserFromRequest(request, supabase); if (!authUser) return new Response("Unauthorized", { status: 401 }); const formData = await request.formData(); @@ -228,16 +229,29 @@ const sectionMap: Record> = { }; export default function SettingsPage() { - const { user } = useLoaderData(); + const { deferredData } = useLoaderData(); const [section, setSection] = useState
("account"); const SectionComponent = sectionMap[section]; return (
setSection(s as Section)} /> -
- -
+ + + Loading settings... +
+ } + > + + {({ user }) => ( +
+ +
+ )} +
+ ); } \ No newline at end of file diff --git a/app/routes/signup-page.tsx b/app/routes/signup-page.tsx index f34dd79..8309b85 100644 --- a/app/routes/signup-page.tsx +++ b/app/routes/signup-page.tsx @@ -1,6 +1,6 @@ import { redirect } from "react-router"; import type { Route } from "./+types/signup-page"; -import { getSupabaseServerClient } from "~/utils/supabase.server"; +import { getSupabaseServerClient, authSessionStorage } from "~/utils/supabase.server"; import { PrismaClient } from "@prisma/client"; import styles from "./signup-page.module.css"; import { SignupForm } from "~/blocks/signup-page/signup-form"; @@ -71,6 +71,13 @@ export async function action({ request }: Route.ActionArgs) { }; } + if (data.user) { + const session = await authSessionStorage.getSession(request.headers.get("Cookie")); + session.set("userId", data.user.id); + session.set("email", data.user.email); + headers.append("Set-Cookie", await authSessionStorage.commitSession(session)); + } + return redirect("/app/dashboard", { headers }); } diff --git a/app/utils/supabase.server.ts b/app/utils/supabase.server.ts index 8e448da..3fa20bd 100644 --- a/app/utils/supabase.server.ts +++ b/app/utils/supabase.server.ts @@ -1,4 +1,5 @@ import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr"; +import { createCookieSessionStorage } from "react-router"; export function getSupabaseServerClient(request: Request) { const headers = new Headers(); @@ -21,3 +22,101 @@ export function getSupabaseServerClient(request: Request) { return { supabase, headers }; } + +const sessionSecret = process.env.ENCRYPTION_SECRET || "fallback-dev-secret-keep-it-32-chars-long!!!"; + +export const authSessionStorage = createCookieSessionStorage({ + cookie: { + name: "__ft_auth_session", + httpOnly: true, + path: "/", + sameSite: "lax", + secrets: [sessionSecret], + secure: process.env.NODE_ENV === "production", + maxAge: 60 * 60 * 24 * 7, // 7 days + }, +}); + +// In-memory cache for user sessions to avoid slow Supabase Auth network requests on every navigation. +interface CachedUser { + user: any; + expiresAt: number; +} + +const tokenCache = new Map(); + +// Sweep expired cache entries periodically to avoid memory leaks/OOM. +if (typeof globalThis !== "undefined") { + const globalAny = globalThis as any; + if (!globalAny.__tokenCacheSweepInterval) { + globalAny.__tokenCacheSweepInterval = setInterval(() => { + const now = Date.now(); + for (const [token, data] of tokenCache.entries()) { + if (data.expiresAt <= now) { + tokenCache.delete(token); + } + } + }, 5 * 60 * 1000); // Run sweep every 5 minutes + + // Unref the interval if running in Node.js environment so it does not block process exit (useful for scripts/testing). + if (globalAny.__tokenCacheSweepInterval.unref) { + globalAny.__tokenCacheSweepInterval.unref(); + } + } +} + +const userPromiseCache = new WeakMap>(); + +export function getUserFromRequest(request: Request, supabase: any): Promise { + let promise = userPromiseCache.get(request); + if (promise) { + return promise; + } + + const resolveUser = async () => { + try { + // 1. First check the signed session cookie (cryptographically verified locally, 0ms network cost) + const session = await authSessionStorage.getSession(request.headers.get("Cookie")); + const userId = session.get("userId"); + const email = session.get("email"); + + if (userId && email) { + return { data: { user: { id: userId, email } }, error: null }; + } + + // 2. Second check the access token in-memory cache + const { data: { session: supabaseSession } } = await supabase.auth.getSession(); + const accessToken = supabaseSession?.access_token; + + if (accessToken) { + const cached = tokenCache.get(accessToken); + if (cached) { + if (cached.expiresAt > Date.now()) { + return { data: { user: cached.user }, error: null }; + } else { + tokenCache.delete(accessToken); + } + } + } + + // 3. Fallback: fetch from Supabase Auth Server (network request) + const result = await supabase.auth.getUser(); + + if (result.data?.user && accessToken) { + tokenCache.set(accessToken, { + user: result.data.user, + expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes TTL + }); + } + return result; + } catch (err) { + // Fallback directly to supabase call on any error + return supabase.auth.getUser(); + } + }; + + promise = resolveUser(); + userPromiseCache.set(request, promise); + return promise; +} +