diff --git a/src/app/(dashboard)/dashboard/account/page.tsx b/src/app/(dashboard)/dashboard/account/page.tsx index 0c4209c8..27df1322 100644 --- a/src/app/(dashboard)/dashboard/account/page.tsx +++ b/src/app/(dashboard)/dashboard/account/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next" import { env } from "@/env.mjs" + import { UserProfile } from "@/components/auth/user-profile" import { PageHeader, @@ -7,7 +8,6 @@ import { PageHeaderHeading, } from "@/components/page-header" import { Shell } from "@/components/shells/shell" -import {dark} from "@clerk/themes"; export const metadata: Metadata = { metadataBase: new URL(env.NEXT_PUBLIC_APP_URL), @@ -33,9 +33,7 @@ export default function AccountPage() { aria-labelledby="user-account-info-heading" className="w-full overflow-hidden" > - + ) diff --git a/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/_components/overview-card.tsx b/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/_components/overview-card.tsx new file mode 100644 index 00000000..0a27aca8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/_components/overview-card.tsx @@ -0,0 +1,49 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Skeleton } from "@/components/ui/skeleton" +import { Icons } from "@/components/icons" + +interface OverviewCardProps { + title: string + value: string + description: string + icon: keyof typeof Icons +} + +export function OverviewCard({ + title, + value, + description, + icon, +}: OverviewCardProps) { + const Icon = Icons[icon] + + return ( + + + {title} + + + + {value} + {description && ( + {description} + )} + + + ) +} + +export function OverviewCardSkeleton() { + return ( + + + + + + + + + + + ) +} diff --git a/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/_components/sales-chart.tsx b/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/_components/sales-chart.tsx new file mode 100644 index 00000000..c001d20a --- /dev/null +++ b/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/_components/sales-chart.tsx @@ -0,0 +1,28 @@ +"use client" + +import { LineChart, type LineChartProps } from "@tremor/react" + +import { cn, formatPrice } from "@/lib/utils" + +interface SalesChartProps + extends Omit { + data: { + name: string + Total: number + }[] +} + +export function SalesChart({ data, className, ...props }: SalesChartProps) { + return ( + formatPrice(value)} + yAxisWidth={48} + {...props} + /> + ) +} diff --git a/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/page.tsx b/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/page.tsx index 0e1eb33b..5cd87fcb 100644 --- a/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/page.tsx +++ b/src/app/(dashboard)/dashboard/stores/[storeId]/analytics/page.tsx @@ -4,21 +4,38 @@ import { db } from "@/db" import { stores } from "@/db/schema" import { env } from "@/env.mjs" import type { SearchParams } from "@/types" +import { format } from "date-fns" import { eq } from "drizzle-orm" +import { z } from "zod" -import { getCustomers, getSalesCount } from "@/lib/fetchers/order" -import { formatNumber, formatPrice } from "@/lib/utils" +import { + getCustomers, + getOrderCount, + getSaleCount, + getSales, +} from "@/lib/fetchers/order" +import { cn, formatNumber, formatPrice } from "@/lib/utils" import { searchParamsSchema } from "@/lib/validations/params" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Card, CardContent, CardDescription, + CardFooter, CardHeader, CardTitle, } from "@/components/ui/card" +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination" import { DateRangePicker } from "@/components/date-range-picker" -import { Icons } from "@/components/icons" + +import { OverviewCard } from "./_components/overview-card" +import { SalesChart } from "./_components/sales-chart" export const metadata: Metadata = { metadataBase: new URL(env.NEXT_PUBLIC_APP_URL), @@ -39,8 +56,11 @@ export default async function AnalyticsPage({ }: AnalyticsPageProps) { const storeId = Number(params.storeId) - const { from, to } = searchParamsSchema - .pick({ from: true, to: true }) + const { page, per_page, from, to } = searchParamsSchema + .omit({ per_page: true, sort: true }) + .extend({ + per_page: z.coerce.number().default(5), + }) .parse(searchParams) const fromDay = from ? new Date(from) : undefined @@ -65,7 +85,19 @@ export default async function AnalyticsPage({ notFound() } - const salesCountPromise = getSalesCount({ + const orderCountPromise = getOrderCount({ + storeId, + fromDay: fromDay, + toDay: toDay, + }) + + const saleCountPromise = getSaleCount({ + storeId, + fromDay: fromDay, + toDay: toDay, + }) + + const salesPromise = getSales({ storeId, fromDay: fromDay, toDay: toDay, @@ -73,14 +105,19 @@ export default async function AnalyticsPage({ const customersPromise = getCustomers({ storeId, + limit: per_page ?? 5, + offset: (page - 1) * per_page, fromDay: fromDay, toDay: toDay, }) - const [sales, customers] = await Promise.all([ - salesCountPromise, - customersPromise, - ]) + const [saleCount, orderCount, sales, { customers, customerCount }] = + await Promise.all([ + saleCountPromise, + orderCountPromise, + salesPromise, + customersPromise, + ]) return ( @@ -88,84 +125,114 @@ export default async function AnalyticsPage({ Analytics - - - - Total Revenue - + + + + + + + + + + Sales + + Total sales in the last {dayCount} days + - - {formatPrice(sales, { - notation: "standard", - })} - - - - - - Sales - ({ + name: format(new Date(sale.year, sale.month - 1), "MMM"), + Total: sale.totalSales, + }))} /> - - - - {formatPrice(sales, { - notation: "standard", - })} - - - Customers - + + Customers + + Customers who have purchased in the last {dayCount} days + - - {customers.length} - - - - - - Recent Customers - - {customers.length} customers{" "} - {dayCount && `in the last ${dayCount} days`} - - - - {customers.map((customer) => ( - - - - - {customer.name?.slice(0, 2).toUpperCase()} - - - + + {customers.map((customer) => ( + + + + + {customer.name?.slice(0, 2).toUpperCase()} + + + + {customer.name} + + {customer.email} + + + - {customer.name} + {formatPrice(customer.totalSpent)} - - {customer.email} - - - - +${formatNumber(customer.totalSpent)} - - ))} - - + ))} + + + + + + + + + + + + + + + ) } diff --git a/src/app/(lobby)/products/page.tsx b/src/app/(lobby)/products/page.tsx index 4422d38b..0ff58c94 100644 --- a/src/app/(lobby)/products/page.tsx +++ b/src/app/(lobby)/products/page.tsx @@ -40,12 +40,9 @@ export default async function ProductsPage({ } = productsSearchParamsSchema.parse(searchParams) // Products transaction - const pageAsNumber = Number(page) - const fallbackPage = - isNaN(pageAsNumber) || pageAsNumber < 1 ? 1 : pageAsNumber - const perPageAsNumber = Number(per_page) + const fallbackPage = isNaN(page) || page < 1 ? 1 : page // Number of items per page - const limit = isNaN(perPageAsNumber) ? 10 : perPageAsNumber + const limit = isNaN(per_page) ? 10 : per_page // Number of items to skip const offset = fallbackPage > 0 ? (fallbackPage - 1) * limit : 0 diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 00000000..507b35ac --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,126 @@ +import * as React from "react" +import Link from "next/link" +import { + ChevronLeftIcon, + ChevronRightIcon, + DotsHorizontalIcon, +} from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" +import { buttonVariants, type ButtonProps } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( + +) +Pagination.displayName = "Pagination" + +const PaginationContent = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + +)) +PaginationContent.displayName = "PaginationContent" + +const PaginationItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ className, ...props }, ref) => ( + +)) +PaginationItem.displayName = "PaginationItem" + +type PaginationLinkProps = { + isActive?: boolean +} & Pick & + React.ComponentProps + +const PaginationLink = ({ + className, + href, + isActive = false, + size = "icon", + ...props +}: PaginationLinkProps) => { + return ( + + ) +} +PaginationLink.displayName = "PaginationLink" + +const PaginationPrevious = ({ + className, + ...props +}: React.ComponentProps) => ( + + + Previous + +) +PaginationPrevious.displayName = "PaginationPrevious" + +const PaginationNext = ({ + className, + ...props +}: React.ComponentProps) => ( + + Next + + +) +PaginationNext.displayName = "PaginationNext" + +const PaginationEllipsis = ({ + className, + ...props +}: React.ComponentProps<"span">) => ( + + + More pages + +) +PaginationEllipsis.displayName = "PaginationEllipsis" + +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} diff --git a/src/lib/fetchers/order.ts b/src/lib/fetchers/order.ts index a41b8266..ca972eb6 100644 --- a/src/lib/fetchers/order.ts +++ b/src/lib/fetchers/order.ts @@ -171,7 +171,6 @@ export async function getOrderLineItems( return lineItems } catch (err) { - console.error(err) return [] } } @@ -286,7 +285,36 @@ export async function getStoreOrders(input: { } } -export async function getSalesCount(input: { +export async function getOrderCount(input: { + storeId: number + fromDay?: Date + toDay?: Date +}) { + noStore() + try { + const { storeId, fromDay, toDay } = input + + return await db + .select({ + count: sql`count(*)`, + }) + .from(orders) + .where( + and( + eq(orders.storeId, storeId), + fromDay && toDay + ? and(gte(orders.createdAt, fromDay), lte(orders.createdAt, toDay)) + : undefined + ) + ) + .execute() + .then((res) => res[0]?.count ?? 0) + } catch (err) { + return 0 + } +} + +export async function getSaleCount(input: { storeId: number fromDay?: Date toDay?: Date @@ -316,12 +344,11 @@ export async function getSalesCount(input: { return sales } catch (err) { - console.error(err) return 0 } } -export async function getCustomers(input: { +export async function getSales(input: { storeId: number fromDay?: Date toDay?: Date @@ -331,25 +358,99 @@ export async function getCustomers(input: { const { storeId, fromDay, toDay } = input return await db - .selectDistinct({ - name: orders.name, - email: orders.email, - totalSpent: sql`sum(${orders.amount})`, + .select({ + year: sql`EXTRACT(YEAR FROM ${orders.createdAt})`.mapWith(Number), + month: sql`EXTRACT(MONTH FROM ${orders.createdAt})`.mapWith(Number), + totalSales: sql`SUM(${orders.amount})`.mapWith(Number), }) .from(orders) .where( and( eq(orders.storeId, storeId), - // Filter by createdAt fromDay && toDay ? and(gte(orders.createdAt, fromDay), lte(orders.createdAt, toDay)) : undefined ) ) - .groupBy(orders.email, orders.name) - .orderBy(desc(sql`sum(${orders.amount})`)) + .groupBy( + sql`EXTRACT(YEAR FROM ${orders.createdAt})`, + sql`EXTRACT(MONTH FROM ${orders.createdAt})` + ) + .orderBy( + sql`EXTRACT(YEAR FROM ${orders.createdAt})`, + sql`EXTRACT(MONTH FROM ${orders.createdAt})` + ) + .execute() } catch (err) { - console.error(err) return [] } } + +export async function getCustomers(input: { + storeId: number + limit: number + offset: number + fromDay?: Date + toDay?: Date +}) { + noStore() + try { + const transaction = await db.transaction(async (tx) => { + const { storeId, limit, offset, fromDay, toDay } = input + + const customers = await tx + .select({ + email: orders.email, + name: orders.name, + totalSpent: sql`sum(${orders.amount})`, + }) + .from(orders) + .where( + and( + eq(orders.storeId, storeId), + fromDay && toDay + ? and( + gte(orders.createdAt, fromDay), + lte(orders.createdAt, toDay) + ) + : undefined + ) + ) + .groupBy(orders.email, orders.name, orders.createdAt) + .orderBy(desc(orders.createdAt)) + .limit(limit) + .offset(offset) + + const customerCount = await tx + .select({ + count: sql`count(distinct ${orders.email})`, + }) + .from(orders) + .where( + and( + eq(orders.storeId, storeId), + fromDay && toDay + ? and( + gte(orders.createdAt, fromDay), + lte(orders.createdAt, toDay) + ) + : undefined + ) + ) + .execute() + .then((res) => res[0]?.count ?? 0) + + return { + customers, + customerCount, + } + }) + + return transaction + } catch (err) { + return { + customers: [], + customerCount: 0, + } + } +} diff --git a/src/lib/fetchers/product.ts b/src/lib/fetchers/product.ts index 13d5ac59..8815b6f3 100644 --- a/src/lib/fetchers/product.ts +++ b/src/lib/fetchers/product.ts @@ -53,8 +53,6 @@ export async function getFeaturedProducts() { } } -export type FeaturedProductsPromise = ReturnType - // See the unstable_noStore API docs: https://nextjs.org/docs/app/api-reference/functions/unstable_noStore export async function getProducts(input: z.infer) { noStore() diff --git a/src/lib/fetchers/store.ts b/src/lib/fetchers/store.ts index dabaae7f..d9617ffd 100644 --- a/src/lib/fetchers/store.ts +++ b/src/lib/fetchers/store.ts @@ -40,8 +40,6 @@ export async function getFeaturedStores() { } } -export type FeaturedStoresPromise = ReturnType - export async function getUserStores(input: { userId: string }) { try { return await cache(
{description}
{customer.name}
+ {customer.email} +
- {customer.name} + {formatPrice(customer.totalSpent)}
- {customer.email} -