Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions app/routes/ai-insights.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}

49 changes: 36 additions & 13 deletions app/routes/ai-insights.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<AiInsightItem[]>([]);
const [analyzing, setAnalyzing] = useState(false);
Expand All @@ -72,7 +74,7 @@ export default function AiInsightsPage() {
};

return (
<div className={styles.page}>
<>
<AiInsightsHeader onAnalyzeAll={triggerAiAnalysis} isPro={isPro} />
{!isPro && <PlanGateMessage />}
{isPro && (analyzing || fetcher.state === "submitting") && (
Expand All @@ -88,6 +90,27 @@ export default function AiInsightsPage() {
onClose={() => setSelectedItem(null)}
/>
)}
</>
);
}

export default function AiInsightsPage() {
const { deferredData } = useLoaderData<typeof loader>();

return (
<div className={styles.page}>
<Suspense
fallback={
<div className={styles.loadingContainer}>
<IconLoader2 size={32} className={styles.spin} />
<span>Loading insights data...</span>
</div>
}
>
<Await resolve={deferredData}>
{(inventory) => <AiInsightsPageContent inventory={inventory} />}
</Await>
</Suspense>
</div>
);
}
8 changes: 6 additions & 2 deletions app/routes/app-layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,13 +13,17 @@ export function headers(_: Route.HeadersArgs) {
};
}

export function shouldRevalidate() {
return false;
}

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 });
Expand Down
10 changes: 9 additions & 1 deletion app/routes/auth.callback.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 });
}
6 changes: 5 additions & 1 deletion app/routes/auth.logout.ts
Original file line number Diff line number Diff line change
@@ -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 });
}

Expand Down
21 changes: 21 additions & 0 deletions app/routes/dashboard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}

141 changes: 85 additions & 56 deletions app/routes/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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<typeof loader>();
const { deferredData } = useLoaderData<typeof loader>();
return (
<div className={styles.page}>
<DashboardHeader />
<AIInsightsPanel />
<StatsCardsRow stats={inventoryStats} sales={salesData} expenses={expensesData} />
<CashFlowChart sales={salesData} expenses={expensesData} />
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: "var(--space-6)",
marginBottom: "var(--space-6)",
}}
<Suspense
fallback={
<div className={styles.loadingContainer}>
<IconLoader2 size={32} className={styles.spin} />
<span>Loading dashboard data...</span>
</div>
}
>
<TopBrandsChart sales={salesData} />
<SalesByMarketplacePie sales={salesData} />
<ExpenseCategoriesChart expenses={expensesData} />
</div>
<TopSellingItemsTable sales={salesData} />
<RecentSales sales={salesData} />
<Await resolve={deferredData}>
{({ inventoryStats, salesData, expensesData }) => (
<>
<StatsCardsRow stats={inventoryStats} sales={salesData} expenses={expensesData} />
<CashFlowChart sales={salesData} expenses={expensesData} />
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gap: "var(--space-6)",
marginBottom: "var(--space-6)",
}}
>
<TopBrandsChart sales={salesData} />
<SalesByMarketplacePie sales={salesData} />
<ExpenseCategoriesChart expenses={expensesData} />
</div>
<TopSellingItemsTable sales={salesData} />
<RecentSales sales={salesData} />
</>
)}
</Await>
</Suspense>
</div>
);
}

Loading
Loading