Open alliyya opened 11 months ago
I did the same thing. This was really annoying have to control F through every chapter.
@alliyya Same here.
I've finished all chapters but http://localhost:3000/dashboard/customers
page only showing customers
text.
So I found correct implementation of app/dashboard/customers/page.tsx
from final-example and then customers page working.
Might be we should update Learn Next course, following @alliyya suggestion
I feel like it should be here: https://nextjs.org/learn/dashboard-app/adding-search-and-pagination based on what's in the final file.
adding customers page modify instruction like this.
app/dashboard/customers/page.tsx
import type { Metadata } from 'next'
+import { fetchFilteredCustomers } from '@/app/lib/data' +import CustomersTable from '@/app/ui/customers/table'
export const metadata: Metadata = { title: 'Customers', }
export default async function Page({
searchParams, +}: {
searchParams?: {
query?: string
page?: string
} +}) {
const query = searchParams?.query || ''
const customers = await fetchFilteredCustomers(query)
return (
) }
@delbaoliveira @balazsorban44 Has Vercel customer support been contacted about the unfinished customers page after completing the Learn Next course?
Just realized the same thing today and thought I forgot something. Glad to see I'm not the only one that was confused by this.
Hello,
based on @ryota-murakami 's model and the course guidelines globaly, below could be a version that uses Suspense and skeletons as well, and some pagination. Some code has been moved from CustomersTable.tsx
to page.tsx
in order to set the suspense boundary around the relevant component.
This could be a good exercise to ask users of the tutorial to reproduce the previous steps on the Customers Page in order to practice and learn better.
Let me know if you see some mistakes or bad practice as I am not experienced.
app\dashboard\customers\page.tsx
import CustomersTable from "@/app/ui/customers/table";
import { lusitana } from "@/app/ui/fonts";
import Search from "@/app/ui/search";
import { CustomersTableSkeleton } from "@/app/ui/skeletons";
import { Metadata } from "next";
import { Suspense } from "react";
export const metadata: Metadata = {
title: "Customers",
};
export default function Page({
searchParams
} : {
searchParams?:
{
query?: string,
page?: string
}
}) {
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
return (
<main className="w-full">
<div className="w-full">
<h1 className={`${lusitana.className} mb-8 text-xl md:text-2xl`}>
Customers
</h1>
<Search placeholder="Search customers..." />
<div className="mt-6 flow-root">
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<Suspense key={query + currentPage} fallback={<CustomersTableSkeleton/>}>
<CustomersTable query={query} currentPage={currentPage}/>
</Suspense>
</div>
</div>
</div>
</div>
</main>
);
}
\app\ui\customers\table.tsx
import Image from 'next/image';
import {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
CustomersTableType,
FormattedCustomersTable,
} from '@/app/lib/definitions';
import { fetchCustomesPages, fetchFilteredCustomers } from '@/app/lib/data';
import Pagination from '../invoices/pagination';
export default async function CustomersTable({
query,
currentPage
} : {
query: string;
currentPage: number;
}) {
let customers: FormattedCustomersTable[] = [];
let totalPages: number = 1;
[customers, totalPages] = await Promise.all([
fetchFilteredCustomers(query, currentPage),
fetchCustomesPages(query),
]);
return (
<>
<div className="overflow-hidden rounded-md bg-gray-50 p-2 md:pt-0">
<div className="md:hidden">
{customers?.map((customer) => (
<div
key={customer.id}
className="mb-2 w-full rounded-md bg-white p-4"
>
<div className="flex items-center justify-between border-b pb-4">
<div>
<div className="mb-2 flex items-center">
<div className="flex items-center gap-3">
<Image
src={customer.image_url}
className="rounded-full"
alt={`${customer.name}'s profile picture`}
width={28}
height={28}
/>
<p>{customer.name}</p>
</div>
</div>
<p className="text-sm text-gray-500">
{customer.email}
</p>
</div>
</div>
<div className="flex w-full items-center justify-between border-b py-5">
<div className="flex w-1/2 flex-col">
<p className="text-xs">Pending</p>
<p className="font-medium">{customer.total_pending}</p>
</div>
<div className="flex w-1/2 flex-col">
<p className="text-xs">Paid</p>
<p className="font-medium">{customer.total_paid}</p>
</div>
</div>
<div className="pt-4 text-sm">
<p>{customer.total_invoices} invoices</p>
</div>
</div>
))}
</div>
<table className="hidden min-w-full rounded-md text-gray-900 md:table">
<thead className="rounded-md bg-gray-50 text-left text-sm font-normal">
<tr>
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
Name
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Total Invoices
</th>
<th scope="col" className="px-3 py-5 font-medium">
Total Pending
</th>
<th scope="col" className="px-4 py-5 font-medium">
Total Paid
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-900">
{customers.map((customer) => (
<tr key={customer.id} className="group">
<td className="whitespace-nowrap bg-white py-5 pl-4 pr-3 text-sm text-black group-first-of-type:rounded-md group-last-of-type:rounded-md sm:pl-6">
<div className="flex items-center gap-3">
<Image
src={customer.image_url}
className="rounded-full"
alt={`${customer.name}'s profile picture`}
width={28}
height={28}
/>
<p>{customer.name}</p>
</div>
</td>
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
{customer.email}
</td>
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
{customer.total_invoices}
</td>
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm">
{customer.total_pending}
</td>
<td className="whitespace-nowrap bg-white px-4 py-5 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md">
{customer.total_paid}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-5 flex w-full justify-center">
<Pagination totalPages={totalPages} />
</div>
</>
);
}
skeletons.tsx
:export function CustomerMobileSkeleton() {
return (
<div className="mb-2 w-full rounded-md bg-white p-4">
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
<div className="flex items-center">
<div className="mr-2 h-8 w-8 rounded-full bg-gray-100"></div>
<div className="h-6 w-16 rounded bg-gray-100"></div>
</div>
<div className="h-6 w-16 rounded bg-gray-100"></div>
</div>
<div className="flex w-full items-center justify-between border-b py-5">
<div className="flex w-1/2 flex-col">
<div className="h-4 w-16 rounded bg-gray-100"></div>
<div className="mt-2 h-6 w-24 rounded bg-gray-100"></div>
</div>
<div className="flex w-1/2 flex-col">
<div className="h-4 w-16 rounded bg-gray-100"></div>
<div className="mt-2 h-6 w-24 rounded bg-gray-100"></div>
</div>
</div>
<div className="pt-4 text-sm">
<div className="h-6 w-24 rounded bg-gray-100"></div>
</div>
</div>
);
}
export function CustomersMobileSkeleton() {
return (
<div className="md:hidden">
<CustomerMobileSkeleton />
<CustomerMobileSkeleton />
<CustomerMobileSkeleton />
<CustomerMobileSkeleton />
<CustomerMobileSkeleton />
</div>
);
}
export function CustomerTableRowSkeleton() {
return (
<tr className="w-full border-b border-gray-100 last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg">
{/* Customer Name and Image */}
<td className="relative overflow-hidden whitespace-nowrap py-3 pl-6 pr-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-gray-100"></div>
<div className="h-6 w-24 rounded bg-gray-100"></div>
</div>
</td>
{/* Email */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-32 rounded bg-gray-100"></div>
</td>
{/* Total Invoices */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
{/* Total Pending */}
<td className="whitespace-nowrap px-3 py-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
{/* Total Paid */}
<td className="whitespace-nowrap py-3 pl-6 pr-3">
<div className="h-6 w-16 rounded bg-gray-100"></div>
</td>
</tr>
);
}
export function PaginationSkeleton(){
return (
<>
<div className="inline-flex">
<div className={'flex h-10 w-10 items-center justify-center rounded-md border' + ' pointer-events-none text-gray-300' + ' mr-2 md:mr-4'}>
<ArrowLeftIcon className="w-4"/>
</div>
<div className="flex -space-x-px">
<div className={'z-10 bg-blue-600 border-blue-600 text-white ' + 'flex h-10 w-10 items-center justify-center text-sm border' + " rounded-l-md"}>1</div>
</div>
<div className="flex -space-x-px">
<div className={'flex h-10 w-10 items-center justify-center text-sm border' + " rounded-r-md"}>2</div>
</div>
<div className={'flex h-10 w-10 items-center justify-center rounded-md border' + ' pointer-events-none text-gray-300' + ' ml-2 md:ml-4'}>
<ArrowRightIcon className="w-4" />
</div>
</div>
</>
)
}
export function CustomersTableSkeleton() {
return (
<>
<div className="overflow-hidden rounded-md bg-gray-50 p-2 md:pt-0">
<CustomersMobileSkeleton />
<table className="hidden min-w-full rounded-md text-gray-900 md:table">
<thead className="rounded-md bg-gray-50 text-left text-sm font-normal">
<tr>
<th scope="col" className="px-4 py-5 font-medium sm:pl-6">
Name
</th>
<th scope="col" className="px-3 py-5 font-medium">
Email
</th>
<th scope="col" className="px-3 py-5 font-medium">
Total Invoices
</th>
<th scope="col" className="px-3 py-5 font-medium">
Total Pending
</th>
<th scope="col" className="px-4 py-5 font-medium">
Total Paid
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-900">
<CustomerTableRowSkeleton />
<CustomerTableRowSkeleton />
<CustomerTableRowSkeleton />
<CustomerTableRowSkeleton />
<CustomerTableRowSkeleton />
</tbody>
</table>
</div>
<div className="mt-5 flex w-full justify-center">
<PaginationSkeleton />
</div>
</>
);
}
data.tsx
you need to change fetchFilteredCustomers
and add a fetchCustomesPages
function : export async function fetchFilteredCustomers(query: string, currentPage: number) {
const offset = (currentPage - 1) * ITEMS_PER_PAGE;
try {
// Artificially delay a response for demo purposes.
// await new Promise((resolve) => setTimeout(resolve, delays[0]));
const data = await sql<CustomersTableType>`
SELECT
customers.id,
customers.name,
customers.email,
customers.image_url,
COUNT(invoices.id) AS total_invoices,
SUM(CASE WHEN invoices.status = 'pending' THEN invoices.amount ELSE 0 END) AS total_pending,
SUM(CASE WHEN invoices.status = 'paid' THEN invoices.amount ELSE 0 END) AS total_paid
FROM customers
LEFT JOIN invoices ON customers.id = invoices.customer_id
WHERE
customers.name ILIKE ${`%${query}%`} OR
customers.email ILIKE ${`%${query}%`}
GROUP BY customers.id, customers.name, customers.email, customers.image_url
ORDER BY customers.name ASC
LIMIT ${ITEMS_PER_PAGE} OFFSET ${offset}
`;
const customers = data.rows.map((customer) => ({
...customer,
total_pending: formatCurrency(customer.total_pending),
total_paid: formatCurrency(customer.total_paid),
}));
return customers;
} catch (err) {
console.error('Database Error:', err);
throw new Error('Failed to fetch customer table.');
}
}
export async function fetchCustomesPages(query: string) {
try {
const count = await sql`
SELECT COUNT(*)
FROM customers
WHERE
name ILIKE ${`%${query}%`} OR
email ILIKE ${`%${query}%`}
`;
const totalPages = Math.ceil(Number(count.rows[0].count) / ITEMS_PER_PAGE);
return totalPages;
} catch (error) {
console.error('Database Error:', error);
throw new Error('Failed to fetch total number of customers.');
}
}
Just completed the course and I seem to be missing the step where the customer page is actually updated to be functional?
I feel like it should be here: https://nextjs.org/learn/dashboard-app/adding-search-and-pagination based on what's in the final file.
Please let me know what step I skipped out on. I went through all the Chapters and tried to
ctrl-f
through the pages forcustomers
and didn't find it. Maybe I just missed it.Otherwise, great course. Would've enjoyed a few more DIY activities for each chapter.