FullStack Next.js z Prisma, Stripe, React Query, Tailwind, NextAuth i TypeScript
- Czas potrzebny na przeczytanie:35 minut
- Opublikowane:
Wprowadzenie
Zanim zaczniemy, jeśli wcześniej nie miałeś kontaktu z Next.js, to przed tym tutorialem zapoznaj się koniecznie z oficjalnym poradnikiem. Poradnik wyjaśnia wszystko czego potrzebujesz by zacząć z Next.js do zera. Ważne, żebyś był zaznajomiony z podstawami, bo w tym tutorialu się na nich nie skupiamy, za to bierzemy na tapet poszczególne technologie i ich praktyczne wykorzystanie w projekcie.
Gotowi? Do dzieła!
Opis projektu
Czym będziemy się dzisiaj tak właściwie zajmować? Zbudujemy mały sklep e-commerce z wykorzystaniem wielu bardzo fajnych technologii. Pokażę Ci jak połączyć narzędzia frontendowe, z tymi backendowymi i stworzyć w pełni działającą aplikacje w Next.js.
Zaimplementujemy uwierzytelnianie, pobierzemy produkty z bazy danych, które później będziemy mogli kupić przy pomocy Stripe - providera do obsługi płatności. Finalny wygląd:
Ten projekt możesz również traktować jak podstawowy budulec do rozbudowy Twojego następnego side projectu. Ze względu na formę tego tutoriala (jeden artykuł), musiałem ograniczyć pewne funkcjonalności. Ale nie martw się, na samym końcu znajdziesz sekcję z kilkoma ficzerami wraz z opisem, o które możesz rozbudować ten sklep 🛒
Poniżej znajdziesz spis treści, który ułatwi Ci nawigowanie po kolejnych tematach 👇
Spis treści
- Konfiguracja
- Baza danych
- Prisma
- Layout
- Produkty
- Koszyk
- Uwierzytelnianie
- Płatności
- Obsługa błędów
- Rozbudowa projektu
- Podusomowanie
Konfiguracja
Zaczynamy od najmniej przyjemnej części, czyli konfiguracji całego środowiska:
Instalacja zależności
Lecimy z instalacją nowego projektu z wykorzystaniem Nexta:
npx create-next-app@latest --ts
Po wygenerowaniu projektu, zróbmy mały porządek. Usuńmy niepotrzebne pliki i style. Następnie zainstalujmy wszystkie potrzebne paczki:
npm install @next-auth/prisma-adapter @prisma/client @sentry/nextjs @stripe/react-stripe-js @stripe/stripe-js @tailwindcss/forms react-query stripe yup && npm install --save-dev @headlessui/react @heroicons/react @tailwindcss/aspect-ratio @types/stripe tailwindcss autoprefixer postcss prettier eslint eslint-config-next eslint-plugin-jsx-a11y eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks husky lint-staged
ESlint & Prettier
Niezbędna dla każdego projektu w JS/TS para narzędzi, czyli ESlint i Prettier. Tutaj sprawa dla Ciebie może wyglądać inaczej, każdy ma swoje ulubione pluginy/zasady, u mnie wygląda to tak:
Prettier:
{
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "all",
"semi": true
}
ESlint:
{
"root": true,
"parser": "@typescript-eslint/parser",
"extends": [
"prettier",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/strict",
"plugin:testing-library/recommended",
"plugin:jest-dom/recommended",
"next",
"next/core-web-vitals"
],
"plugins": [
"jsx-a11y",
"react-app",
"react-hooks",
"jest-dom",
"testing-library",
"@typescript-eslint",
"prettier"
],
"env": {
"es6": true,
"browser": true,
"jest": true,
"node": true
},
"rules": {},
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
}
}
Husky & Lint-staged
O ile o poprzednich narzędziach każdy na pewno słyszał, to o tej dwójce już niekoniecznie. Te narzędzia w połączeniu pozwalają nam korzystać z tzw. Git hooks i odpalać konkretne skrypty przed np. zacommitowaniem zmian. A jakie mogą być to skrypty i dlaczego właściwie chcielibyśmy coś takiego robić? 🤔 Idealnym przykładem może być odpalenie ESlinta i Prettiera, po to, żeby kod w zdalnym repozytorium nie miał jakiś boli i żeby spełniał określone reguły.
Instalacja:
npx mrm@2 lint-staged
Ten skrypt wygeneruje nam folder .husky
w katalogu głównym. To w nim możemy tworzyć poszczególne hooki. W naszym przypadku ograniczymy się tylko do fazy pre-commit i odpalimy konkretny skrypt:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run pre-commit
Żeby całość zadziałała tak, jak tego chcemy, musimy uzupełnić nasz package.json
:
"scripts": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yaml,yml,scss,css}": [
"prettier --write"
],
"*.js": "eslint --cache --fix"
}
TailwindCSS
Przez jednych kochany, przez drugich znienawidzony, TailwindCSS. Ja jestem gdzieś po środku, ani nie kocham Tailwinda, ani mi on nie przeszkadza. Dlaczego więc właśnie go wybrałem do tego projektu? Na pewno nie można mu odmówić jednego, że bardzo szybko się w nim prototypuje projekty i właśnie to zadecydowało o jego miejscu tutaj 🏎️
Konfiguracja jest banalnie prosta, wystarczy jedna komenda:
npx tailwindcss init -p
Generuje ona dwa pliki: tailwind.config.js
i postcss.config.js
. My będziemy rozbudowywać ten pierwszy:
module.exports = {
purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
darkMode: false,
theme: {
extend: {},
},
variants: {
extend: { cursor: ['hover', 'focus'] },
},
plugins: [require('@tailwindcss/aspect-ratio'), require('@tailwindcss/forms')],
};
W pliku konfiguracyjnym dodajemy purge
, czyli opcje, która umożliwi nam pozbycie się nieużywanych styli na produkcji. Oprócz tego dodajemy dwa pluginy: @tailwindcss/aspect-ratio
i @tailwindcss/forms
.
Z racji tego, że będziemy korzystać tylko z tego frameworka, bez innych customowych styli, wystarczy, że dodamy import w specjalnym pliku _app.tsx
import 'tailwindcss/tailwind.css';
Voilà ✨
React Query
React Query to świetna biblioteka, która w prosty sposób umożliwia nam zarządzanie tzw. Server State. Czekaj, czym? W aplikacjach frontendowych możemy wyróżnić dwa podstawowe stany:
- Client State
- Server State
Ten pierwszy, obsługuje rzeczy, które nie zmieniają się jakoś często. Na pewno dobrze znacie globalny stan dla motywów, otwarcie/zamknięcie menu itp itd. Server State jest tym, co pobieramy z API, czyli w naszym przypadku będą to np. wszystkie produkty. Taki stan charakteryzuje się zupełnie innymi wymaganiami niż Client State. Paginacja, cache, infinite scroll, prefetching, refetching to tylko niektóre z problemów, które rozwiązuje React Query. Ta libka dostarcza nam szereg przydatnych, w pełni otypowanych hooków, które możemy wykorzystać w projekcie.
Konfiguracja jest bardzo prosta, w _app.tsx
dodajemy provider QueryClientProvider
. Opakowujemy również nasz komponent w Hydrate
, czyli specjalny provider dla tzw. hydracji, o której powiemy sobie trochę później:
import { useState } from 'react';
import type { AppProps } from 'next/app';
import { Hydrate, QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
import 'tailwindcss/tailwind.css';
export default function App({ Component, pageProps }: AppProps) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
<ReactQueryDevtools />
</QueryClientProvider>
);
}
Zmienne środowiskowe
Ostatnia rzecz na naszej konfiguracyjnej liście, czyli zmienne środowiskowe. Na razie nie będziemy ich uzupełniać, za to stworzymy małą funkcje pomocniczą, która ułatwi nam pracę z nimi.
Na samym początku deklarujemy typy dla naszych zmiennych:
type NameToType = {
readonly ENV: 'production' | 'staging' | 'development' | 'test';
readonly NODE_ENV: 'production' | 'development';
readonly PORT: number;
};
Następnie implementujemy właściwą już funkcje getEnv
. Korzystamy tutaj z tzw. przeładowania funkcji. Ta funkcja daje nam dwie rzeczy:
- Autouzupełnianie nazw
- Właściwy typ zmiennej
export function getEnv<Env extends keyof NameToType>(name: Env): NameToType[Env];
export function getEnv(name: keyof NameToType): NameToType[keyof NameToType] {
const val = process.env[name];
if (!val) {
throw new Error(`Cannot find environmental variable: ${name}`);
}
return val;
}
Zobaczmy na jej wywołanie, funkcja nie dość, że podpowiada nam nazwę, to jeszcze wypluwa wcześniej określony typ, magia 🪄
Baza danych
Do naszego projektu wybrałem PostgreSQL jako bazę danych, zarządzać zapytaniami będziemy przez Prismę, która jest wysokopoziomowym ORM, ale zanim to zrobimy skonfigurujmy Dockera:
Kontenery i Docker
Jeśli jeszcze nie wiesz czym jest Docker, to zachęcam Cię do sprawdzenia dedykowanego filmu z wyjaśnieniem. Ja mistrzem Dockera nie jestem, dlatego korzystam z wcześniej przygotowanych setupów.
W naszym przypadku, taki setup wrzucamy do specjalnego pliku docker-compose.yml
, o którym więcej przeczytasz w dokumentacji. W samej konfiguracji deklarujemy dedykowany obraz dla Postgresa z konkretną wersją, wolumeny oraz porty.
version: '3.8'
services:
postgres:
image: postgres:14
restart: always
env_file:
- .env
volumes:
- postgres:/var/lib/postgresql/data
ports:
- '5432:5432'
volumes:
postgres:
Zwróć uwagę na env_file
, to tutaj deklarujemy ścieżkę do naszych zmiennych środowiskowych. Musimy je uzupełnić następującymi danymi:
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
DATABASE_URL="postgresql://<POSTGRES_USER>:<POSTGRES_PASSWORD>@<POSTGRES_HOST>:<POSTGRES_PORT>/<POSTGRES_DB>?schema=public&sslmode=prefer"
Wystarczy nam teraz odpalić aplikację:
docker-compose up -d
Jeśli korzystasz z Docker Desktop, powinieneś widzieć tak działające kontenery:
Prisma
Jak już jesteśmy przy bazach danych, to przejdźmy do naszego rozwiązania ORM, czyli Prismy. Szczerze mówiąc to jestem wielkim fanem tej technologii, praca z nią to czysta przyjemność... Ale zacznijmy od początku, czym w ogóle jest Prisma?
Prisma jest rozwiązaniem ORM, które w pełni wspiera TypeScripta. Prisma udostępnia nam również szereg przydatnych narzędzi do migracji, czy wizualizacji danych takich jak prisma studio
. Oferuje nam ona w pełni otypowany, wysokopoziomowy klient bazy danych. Bez zbędnego gadania, sprawdźmy jak to działa!
Tworzenie schemy
W pierwszej kolejności do pracy z Prismą niezbędna jest tzw. schema. To w niej będziemy mieć obraz tego, jak wyglądają nasze dane i relacje między nimi. Mamy dwie opcje, jeśli mamy zamodelowane wcześniej dane w PostgreSQL, to możemy wygenerować scheme automatycznie. Jeśli jednak, tak jak w naszym przypadku, startujemy od zera, to musimy stworzyć schemat danych ręcznie.
Jeśli pracujesz z VSCode, przy pracy z Prismą Twoim najlepszym przyjacielem będzie dedykowany plugin, który koloruje składnie, formatuje i uzupełnia plik ze schemą.
W katalogu głównym stwórz folder prisma
, a w nim plik schema.prisma
:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
Na samym początku definiujemy podstawową konfigurację i provider, czyli wcześniej wybraną bazę danych. Prismę możemy wykorzystywać z różnymi narzędziami począwszy od właśnie Postgresa, idąc przez MySQL, SQLite, a kończąc na MongoDB.
Zdefiniujmy nasz pierwszy model, model użytkownika:
model User {
id String @id @default(cuid())
name String?
email String? @unique
}
Na początku składnia może Ci się wydawać dziwna, ale uwierz mi, że po krótkim zapoznaniu jest ona bardzo intuicyjna. Model zawsze deklarujemy z dużej litery, a w nim opisujemy poszczególne pola. Każde pole posiada swój typ, który może Ci przypominać typy chociażby z TypeScripta oraz opcjonalnie listę atrybutów(@
).
W tym przypadku wszystkie nasze pola są typu String
. Identyfikator posiada dwa atrybuty, pierwszy @id
odpowiada PRIMARY KEY
w bazie danych. Konstrukcja @default(cuid())
będzie nam generowała automatycznie id
. Dodajmy więcej pól:
enum Role {
USER
ADMIN
}
model User {
id String @id @default(cuid())
role Role @default(USER)
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
accounts Account[]
sessions Session[]
@@map("users")
}
Pojawiło nam się tutaj sporo nowości. Zacznijmy od enuma, enum działa podobnie jak w językach programowania, np. w TypeScript. Możemy go rozumieć tak, że rolą użytkownika będzie albo USER
albo ADMIN
. Atrybutu @unique
używamy do wskazania unikalnych wartości lub kombinacji wartości (@@unique([pole1, pole2])
). A o co chodzi z tymi mapami?
Atrybut @map("email_verified")
mapuje nam nazwę tego pola tak, aby w bazie danych była ona zapisana w innej konwencji nazewniczej. Podobnie jest z atrybutem @@map("users")
, w tym przypadku zmieniamy nie nazwę atrybutu, a modelu i tabeli.
A o co chodzi z Account[]
i Session[]
? Tutaj do gry wchodzą relacje. Weźmy za przykład sesję, mamy tutaj do czynienia z relacją jeden do wielu, czyli użytkownik może mieć wiele sesji, ale sesja tylko jednego użytkownika, logiczne, prawda?
enum Role {
USER
ADMIN
}
model User {
id String @id @default(cuid())
role Role @default(USER)
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
accounts Account[]
sessions Session[]
@@map("users")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
W Session
używamy specjalnego atrybutu @relation
, w którym deklarujemy fields
, czyli pola obecnego modelu, references
, czyli pola relacyjnego modelu oraz onDelete
. onDelete
jest tzw. akcją referencyjną, czyli "co się stanie, gdy użytkownik zostanie usunięty"? W naszym przypadku, zostanie usunięta również sesja.
Cała schema:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String @map("user_id")
type String
provider String
providerAccountId String @map("provider_account_id")
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@map("accounts")
}
enum Role {
USER
ADMIN
}
model User {
id String @id @default(cuid())
role Role @default(USER)
name String?
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
accounts Account[]
sessions Session[]
@@map("users")
}
model Product {
id String @id
description String
name String
price Int
image String
}
model Session {
id String @id @default(cuid())
sessionToken String @unique @map("session_token")
userId String @map("user_id")
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@map("verificationtokens")
}
Migracje
Po tym, jak skończyliśmy definiować scheme, musimy wygenerować migrację. Prisma tutaj znowu przychodzi nam z pomocą i dzieje się to w niej praktycznie z automatu:
npx prisma migrate dev
Po wygenerowaniu, migracje będą dostępne w katalogu prisma
:
Żeby mieć najnowsze dane w kliencie Prismy, po każdej zmianie w schemie powinniśmy wykonać polecenie:
npx prisma generate
Newsletter dla Frontend Developerów 📮
Layout
Uff, odpocznijmy na chwile od baz danych i przejdźmy do tego co frontendowcy lubią najbardziej, czyli centrowania diva.
Nasz Header
w przyszłości będzie odpowiadał za wyświetlenie zdjęcia użytkownika oraz za akcje wylogowania, ale na ten moment skupmy się na samej strukturze:
export const Header = () => {
return (
<header className="relative bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6">
<nav className="flex justify-between items-center border-b-2 border-gray-100 py-6 md:justify-start md:space-x-10">
<Logo />
<div className="hidden md:flex items-center justify-end md:flex-1 lg:w-0">
<img src="" className="w-16 h-16 rounded-full" alt="" />
<button className="ml-8 whitespace-nowrap inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700">
Wyloguj się
</button>
</div>
</nav>
</div>
</header>
);
};
Logo:
export const Logo = () => (
<div className="flex justify-start lg:w-0 lg:flex-1">
<a href="#">
<img
className="h-8 w-auto sm:h-10"
src="https://tailwindui.com/img/logos/workflow-mark-indigo-600.svg"
alt=""
width={40}
height={44}
/>
</a>
</div>
);
Tak przygotowany komponent chcielibyśmy umieścić w komponencie Layout
, który oplecie wszystkie nasze strony:
import type { ReactNode } from 'react';
import { Header } from './header/Header';
type LayoutProps = {
readonly children: ReactNode;
};
export const Layout = ({ children }: LayoutProps) => (
<>
<Header />
<main className="h-full w-full bg-white py-16 px-4 flex flex-col items-center justify-center">
<h1 className="text-5xl font-extrabold tracking-tight text-gray-900 self-center">
FullStack Next.js E-commerce
</h1>
{children}
</main>
</>
);
Zwróć uwagę na typ podawanych przeze mnie propsów i sposób typowania komponentów. Celowo nie korzystam tutaj z React.FC
, więcej o wadach tego typu mówiłem w artykule React Children + TypeScript. Propsy deklaruje z dodatkiem readonly
, po prostu lubię mieć niemutowalne dane.
Tak przygotowany kawałek kodu używamy na stronie głównej:
export default function Home() {
return <Layout>...</Layout>;
}
Dodajmy jeszcze brakującą specjalną stronę w _document.tsx
:
import Document, { Head, Html, NextScript, DocumentContext } from 'next/document';
import { Layout } from '../components/layout/Layout';
export default class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx);
return { ...initialProps };
}
render() {
return (
<Html lang="pl-PL" className="h-full">
<Head></Head>
<body className="h-full">
<NextScript />
</body>
</Html>
);
}
}
Jeśli wszystko poszło zgodnie z planem, to Header
w naszej aplikacji powinien wyglądać następująco:
Produkty
Lecimy w końcu z czymś konkretnym, czyli utworzeniem listy produktów:
Karta produktu
Zacznijmy od komponentu karty i stworzenia pliku Product.tsx
:
import type Prisma from '@prisma/client';
type ProductProps = Readonly<Prisma.Product>;
export const Product = (product: ProductProps) => {
const { id, image, name, price } = product;
return (
<article className="group relative">
<div className="w-full min-h-80 bg-gray-200 aspect-w-1 aspect-h-1 rounded-md overflow-hidden lg:h-80 lg:aspect-none">
<img className="w-full h-full object-center object-cover lg:w-full lg:h-full" alt="" />
</div>
<div className="mt-4 flex justify-between">
<h2 className="text-sm text-gray-700">
<span aria-hidden="true" className="absolute inset-0" />
</h2>
<p className="text-sm font-medium text-gray-900"></p>
</div>
<button className="mt-6 group outline-none relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none">
Kup
</button>
<button className="mt-4 group outline-none relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-black bg-gray-100 hover:bg-gray-300 focus:outline-none">
Dodaj do koszyka
</button>
</article>
);
};
To co na pewno przykuło Twoją uwagę to typ Prisma.Product
. Tutaj właśnie zaczyna się pojawiać cała magia Prismy, generuje ona z wcześniej stworzonej schemy TypeScriptowe typy. Dla każdego modelu mamy osobny typ:
import type Prisma from '@prisma/client';
// type ProductProps = {
// readonly id: string;
// readonly description: string;
// readonly name: string;
// readonly price: number;
// readonly image: string;
// }; 👇
type ProductProps = Readonly<Prisma.Product>;
Endpoint z Next API Routes
Zaczęliśmy mówić o typach, a jeszcze nie pobraliśmy danych! Do pobrania danych z bazy wykorzystamy oczywiście Prismę w połączeniu z tzw. API Routes z Nexta. W katalogu pages
tworzymy folder api
, a w nim products/index.ts
. Jeśli miałeś wcześniej do czynienia z Expressem, ta konstrukcja będzie dla Ciebie znajoma. Żeby stworzyć API Route potrzebujemy po prostu wyeksportować funkcję:
import type { NextApiRequest, NextApiResponse } from 'next';
export default async (req: NextApiRequest, res: NextApiResponse) => {};
Mamy w niej dostęp do specjalnych obiektów żądania i odpowiedzi przygotowanych przez Nexta. Jak już Ci wspominałem, Prisma oferuje nam wysokopoziomowy klient dla bazy danych, zobacz w jak prosty sposób możemy zaciągnąć wszystkie produkty:
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
export default async (req: NextApiRequest, res: NextApiResponse) => {
const prisma = new PrismaClient();
const products = await prisma.product.findMany(); // typ: Array<Prisma.Product>
};
Na samym początku inicjalizujemy nowego klienta, z którego później możemy skorzystać i pobrać produkty. Korzystamy tutaj z metody findMany
, która zwraca tablicę produktów typu Array<Prisma.Product>
, dokładnie tak samo jak zadeklarowaliśmy w komponencie Product
.
Co najlepsze takie zapytania możemy w różny sposób modyfikować, np. wybrać tyle użytkowników o określonej nazwie:
const users = await prisma.user.findMany({
where: {
name: 'Olaf',
},
})```
Dokładnie tak samo sprawa wygląda z relacjami.
Nasz cały endpoint:
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
export default async (req: NextApiRequest, res: NextApiResponse) => {
const prisma = new PrismaClient();
const products = await prisma.product.findMany();
if (products.length) {
res.status(200).json(products);
res.end();
} else {
res.status(404);
res.end();
}
};
Zaciąganie produktów z React Query
Naszym bazowym budulcem przy fetchowaniu produktów z API będzie pomocnicza funkcja fetcher
, którą na pewno niejednokrotnie implementowałeś w swoich projektach, zobaczmy jak będzie to wyglądało w naszym przypadku. To co będzie nieco niestandardowe w naszym przypadku, to wykorzystanie walidacji i biblioteki Yup, zobaczmy jak wyglądają typy:
import type { AnySchema, InferType } from 'yup';
export type HTTPMethod =
| 'GET'
| 'HEAD'
| 'POST'
| 'PUT'
| 'DELETE'
| 'CONNECT'
| 'OPTIONS'
| 'TRACE'
| 'PATCH';
type FetcherConfig<Schema extends AnySchema | null> = {
readonly method: HTTPMethod;
readonly schema: Schema;
readonly body?: object;
readonly config?: RequestInit;
};
Deklarujemy generyczny typ FetcherConfig
, który będzie przyjmował dowolną schemę z Yup'a lub null
. Wewnątrz określamy jeszcze typ metody HTTP, body i konfigurację funkcji fetch()
.
W przypadku fetchera, tak jak przy zmiennych środowiskowych, korzystamy z przeładowania funkcji. Pod maską kryje się zwykły fetch
oraz sprawdzanie poprawności schemy z wykorzystaniem metody cast()
z Yup'a:
import { ResponseError } from './responseError';
export async function fetcher<Schema extends null>(
path: string,
{ method, body, config, schema }: FetcherConfig<Schema>,
): Promise<null>;
export async function fetcher<Schema extends AnySchema>(
path: string,
{ method, body, config, schema }: FetcherConfig<Schema>,
): Promise<InferType<Schema>>;
export async function fetcher<Schema extends AnySchema | null>(
path: string,
{ method, body, config, schema }: FetcherConfig<Schema>,
) {
try {
const response = await fetch(path, {
...config,
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
method,
...(body && { body: JSON.stringify(body) }),
});
if (response.ok) {
if (!schema) {
return null;
}
const data = await response.json();
return schema.cast(data);
}
throw new ResponseError(response.statusText, response.status);
} catch (err) {
if (err instanceof ResponseError) {
throw err;
}
throw new ResponseError('Something went wrong during fetching!');
}
}
W środku rzucamy wyjątkiem ResponseError
, który jest customową klasą dziedziczącą po Error
:
export class ResponseError extends Error {
constructor(message: string, public readonly status?: number) {
super(message);
this.name = 'ResponseError';
Object.setPrototypeOf(this, ResponseError.prototype);
}
}
Ta funkcja na pierwszy rzut oka może wydawać Ci się skomplikowana, nic bardziej mylnego, trochę obraz zaciemnia to przeładowanie funkcyjne, ale logika nie jest zawiła. Zobaczmy jak to działa w praktyce:
export const getProducts = async () => {
return await fetcher('/api/products', {
method: 'GET',
schema: productsSchema,
});
};
W katalogu products/api/
definiujemy funkcje getProducts
, która oplata fetchera. Ostatnim brakującym elementem w naszej układance jest schema.
Konstruujemy w niej zarys danych. Zwróć uwagę na typ y.SchemaOf<Prisma.Product>
, dzięki niemu wymuszamy na walidatorze konkretny typ, w tym przypadku typ produktu z Prismy:
import * as y from 'yup';
import type Prisma from '@prisma/client';
export const productSchema: y.SchemaOf<Prisma.Product> = y.object().shape({
id: y.string().required(),
description: y.string().required(),
name: y.string().required(),
price: y.number().required(),
image: y.string().required(),
});
export const productsSchema = y.array(productSchema);
Nasze pomocnicze funkcje przygotowane, więc możemy przejść do React Query. Biblioteka udostępnia nam specjalnego hooka useQuery
, do którego podajemy id zapytania products
oraz funkcję, która zwraca promise:
import { useQuery } from 'react-query';
import { getProducts } from '../api/getProducts';
export const useGetProducts = () => {
return useQuery('products', getProducts);
};
Ja lubię każdą taką konstrukcję opakowywać w jeszcze osobnego hooka. Po pierwszy możemy go wtedy dowolnie reużywać, a poza tym całość wygląda nieco bardziej czytelnie.
Gdy hook jest już przygotowany, możemy w końcu zabrać się za wyświetlenie produktów z realnymi danymi i stworzyć komponent Products
:
import { Product } from './Product';
import { useGetProducts } from './hooks/useGetProducts';
export const Products = () => {
const { data: products } = useGetProducts();
return (
<div className="bg-white max-w-2xl mx-auto py-16 px-4 sm:py-24 sm:px-6 lg:max-w-7xl lg:px-8 mt-6 grid grid-cols-1 gap-y-10 gap-x-6 sm:grid-cols-2 lg:grid-cols-4 xl:gap-x-8">
{products && products.map((product) => <Product key={product.id} {...product} />)}
</div>
);
};
Ostatni temat na naszej liście jeśli chodzi o pobieranie produktów, czyli hydracja. W pliku _app.tsx
zadeklarowaliśmy wrapper Hydrate
na nasz komponent:
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
Hydracja z React Query, w połączeniu z Nextem, pozwala nam pobrać potrzebne dane na serwerze, np. podczas builda. Tylko właściwie po co to wszystko, skoro moglibyśmy po prostu użyć useGetProducts()
w komponencie i pobrać dane po stronie klienta? Wykorzystanie hydracji sprawia, że nie będziemy musieli w ogólne czekać na dane, będą one dostępne natychmiasto. Do uzyskania takiego efektu wystarczy nam stworzenie nowego klienta QueryClient
oraz wywołanie metody prefetchQuery
z odpowiednim id
zapytania oraz funkcją, która pobierze potrzebne dane:
import type { GetStaticProps } from 'next';
import { dehydrate, QueryClient } from 'react-query';
import { Products } from '../components/products/Products';
import { getProducts } from '../components/products/api/getProducts';
import { Layout } from '../components/layout/Layout';
export default function Home() {
return (
<Layout>
<Products />
</Layout>
);
}
export const getStaticProps: GetStaticProps = async () => {
const queryClient = new QueryClient();
await queryClient.prefetchQuery('products', getProducts);
return {
props: { dehydratedState: dehydrate(queryClient) },
};
};
W tym przypadku korzystamy z podejścia SSG wykorzystując funkcję getStaticProps
, ale równie dobrze moglibyśmy to samo zrobić korzystając z Server Side Renderingu i getServerSideProps
. To podejście jest dość niecodzienne, normalnie w getStaticProps
zwracamy obiekt z propsami, który później możemy wykorzystać z komponencie, tutaj sprawa wygląda zupełnie inaczej. W tym przypadku nasze propsy wykorzystujemy nie w komponencie samej strony, a w pliku _app.tsx
w komponencie Hydrate
. Dzięki temu możemy nie tylko mieć natychmiastowo pobrane dane, ale również wykorzystywać je w podrzędnych komponentach bez przekazywania propsów w dół.
Koszyk
W naszej aplikacji będziemy mieli dwa sposoby na zakup produktu. Numero uno, pojedyńczy zakup oraz numero duo, czyli koszyk zakupowy 🛒
Koszyk w TailwindCSS
Koszyk będzie się składał z trzech bazowych komponentów, na pierwszy ogień leci CartItem
:
import type Prisma from '@prisma/client';
type CartItemProps = Prisma.Product;
export const CartItem = (product: CartItemProps) => {
return (
<li className="py-6 flex">
<div className="flex-shrink-0 w-24 h-24 border border-gray-200 rounded-md overflow-hidden">
<img alt="" className="w-full h-full object-center object-cover" />
</div>
<div className="ml-4 flex-1 flex flex-col">
<div className="flex justify-between text-base font-medium text-gray-900">
<h3></h3>
<p className="ml-4"></p>
</div>
<div className="flex-1 flex items-end justify-between text-sm">
<div className="flex">
<button type="button" className="font-medium text-indigo-600 hover:text-indigo-500">
Usuń
</button>
</div>
</div>
</div>
</li>
);
};
Korzystamy tutaj ponownie z typów z Prismy, na pewno zauważyłeś, że tworzę tutaj alias CartItemProps
dla typu Prisma.Product
. Nie stoi za tym żadna większa filozofia, a bardziej trzymanie się pewnej konwencji. Bardzo podobnie będzie wyglądał komponent CartItems
, który mapuje po products
i wypluwa listę produktów:
import type Prisma from '@prisma/client';
import { CartItem } from './CartItem';
type CartItemsProps = {
readonly products: Array<Prisma.Product>;
};
export const CartItems = ({ products }: CartItemsProps) => (
<ul className="-my-6 divide-y divide-gray-200">
{products.map((product) => (
<CartItem key={product.id} {...product} />
))}
</ul>
);
Ostatni z trójki komponentów koszyka: Checkout
. To w nim umieścimy część logiki odpowiadającej za otwieranie/zamykanie menu, czy przejście do płatności. Poza samymi stylami korzystam tutaj jeszcze z biblioteki @headlessui
. Jest to bardzo fajna libka od twórców Tailwinda. Oferuje ona w pełni dostępne, nieostylowane komponenty, gotowe do szybkiego użycia:
import { Dispatch, Fragment, SetStateAction } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XIcon } from '@heroicons/react/outline';
import { CartItems } from './CartItems';
export const Checkout = () => {
return (
<Transition.Root as={Fragment}>
<Dialog as="div" className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<Transition.Child
as={Fragment}
enter="ease-in-out duration-500"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in-out duration-500"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="absolute inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex">
<Transition.Child
as={Fragment}
enter="transform transition ease-in-out duration-500 sm:duration-700"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-500 sm:duration-700"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<div className="w-screen max-w-md">
<div className="h-full flex flex-col bg-white shadow-xl overflow-y-scroll">
<div className="flex-1 py-6 overflow-y-auto px-4 sm:px-6">
<div className="flex items-start justify-between">
<Dialog.Title className="text-lg font-medium text-gray-900">
Koszyk
</Dialog.Title>
<div className="ml-3 h-7 flex items-center">
<button
type="button"
className="-m-2 p-2 text-gray-400 hover:text-gray-500"
>
<span className="sr-only">Zamknij</span>
<XIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
</div>
<div className="mt-8 flow-root">
<CartItems products={products} />
</div>
</div>
<div className="border-t border-gray-200 py-6 px-4 sm:px-6">
<div className="flex justify-between text-base font-medium text-gray-900">
<p></p>
<p></p>
</div>
<div className="mt-6">
{products.length > 0 ? (
<button className="w-full flex justify-center items-center px-6 py-3 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-indigo-600 hover:bg-indigo-700">
Do kasy
</button>
) : null}
</div>
</div>
</div>
</div>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
Globalny stan z React.Context
UI gotowe, to pora na prawdziwe mięsko, czyli logikę koszyka. W tym przypadku, zamiast wymyślnych technologii do zarządzania stanem, postanowiłem pójść w klasykę, czyli React.Context
.
import { ReactNode, createContext, useReducer, useContext, useMemo, useState } from 'react';
import { cartReducer } from './reducers/cartReducer';
import type { Action, State } from './types';
type Dispatch = (action: Action) => void;
type CartProviderProps = { readonly children: React.ReactNode };
export const CartStateContext = createContext<{ state: State; dispatch: Dispatch } | undefined>(
undefined,
);
const initialState: State = { products: [], totalPrice: 0, isOpen: false };
export const CartProvider = ({ children }: CartProviderProps) => {
const [state, dispatch] = useReducer(cartReducer, initialState);
const value = useMemo(() => ({ state, dispatch }), [state]);
return <CartStateContext.Provider value={value}>{children}</CartStateContext.Provider>;
};
Jeśli pracowałeś wcześniej z contextem, to nie powinno być tutaj dla Ciebie żadnych niespodzianek. Jeden fajny tip, który mogę Ci sprzedać to wykorzystanie React.useMemo()
przy wartości podawanej do Providera, potrafi to zrobić niemałą różnicę w wydajności.
Typy wyglądają następująco, deklarujemy akcje, które są unią obiektów oraz stan, który będziemy przekazywać do Providera:
import type Prisma from '@prisma/client';
export type Action =
| { type: 'addProduct'; payload: Prisma.Product }
| { type: 'deleteProduct'; payload: Prisma.Product }
| { type: 'openMenu' }
| { type: 'closeMenu' };
export type State = {
readonly products: Array<Prisma.Product>;
readonly totalPrice: number;
readonly isOpen: boolean;
};
W cartReducer
kryje się cała logika. To tutaj deklarujemy akcje dla koszyka addProduct
i deleteProduct
. Kalkulujemy również łączną wartość koszyka oraz podajemy akcje odpowiadające za stan jego otwarcia:
import type Prisma from '@prisma/client';
import type { Action, State } from '../types';
const calculateTotalPrice = (products: Array<Prisma.Product>) => {
return products.reduce((acc, curr) => acc + curr.price, 0);
};
export const cartReducer = (state: State, action: Action) => {
switch (action.type) {
case 'addProduct': {
const products = [...state.products];
const newProduct = action.payload;
const isTheNewProductInCart = products.find((product) => product.id === newProduct.id);
const newProducts = [newProduct, ...products];
const totalPrice = calculateTotalPrice(newProducts);
if (!isTheNewProductInCart) {
return {
...state,
products: newProducts,
totalPrice,
};
}
}
case 'deleteProduct': {
const products = [...state.products];
const productToDelete = action.payload;
const newProducts = products.filter((product) => product.id !== productToDelete.id);
const totalPrice = calculateTotalPrice(newProducts);
return {
...state,
products: newProducts,
totalPrice,
};
}
case 'openMenu': {
return {
...state,
isOpen: true,
};
}
case 'closeMenu': {
return {
...state,
isOpen: false,
};
}
default: {
throw new Error(`Unhandled action type`);
}
}
};
Gdy nasz context jest już gotowy, możemy dodać Provider do _app.tsx
:
<Hydrate state={pageProps.dehydratedState}>
<CartProvider>
<Component {...pageProps} err={err} />
</CartProvider>
</Hydrate>
Bardzo często spotykaną praktyką podczas wykorzystania contextu, jest tworzenie specjalnego hooka zwracającego wartość stanu, nie inaczej jest w naszym przypadku:
import { useMemo, useContext } from 'react';
import { CartStateContext } from '../context/cartContext';
export const useCart = () => {
const context = useContext(CartStateContext);
if (context === undefined) {
throw new Error('useCount must be used within a CountProvider');
}
return useMemo(() => context, [context]);
};
W komponencie Checkout
wykorzystujemy dane z hooka i określamy akcje dla stanu menu:
export const Checkout = () => {
const {
state: { totalPrice, products, isOpen },
dispatch,
} = useCart();
const handleOpenMenu = () => dispatch({ type: 'openMenu' });
const handleCloseMenu = () => dispatch({ type: 'closeMenu' });
...
};
Context przyda nam się również w CartItem
, gdzie będziemy korzystali z akcji usuwania produktu z koszyka:
export const CartItem = (product: CartItemProps) => {
const { id, name, price, image } = product;
const { dispatch } = useCart();
const handleDelete = (product: Prisma.Product) => {
dispatch({ type: "deleteProduct", payload: product });
};
...
Ostatnim miejscem, które potrzebuje danych z naszego contextu jest komponent Product
:
export const Product = (product: ProductProps) => {
const { id, image, name, price } = product;
const { dispatch } = useCart();
const addToCart = () => {
dispatch({ type: 'addProduct', payload: product });
dispatch({ type: 'openMenu' });
};
...
};
Uwierzytelnianie
Prawie każda współczesna aplikacja posiada system logowania użytkowników, dlatego tej ważnej funkcjonalności nie mogło zabraknąć w naszym sklepie. Uwierzytelnianie w aplikacjach często wygląda w bardzo podobny sposób, dlatego społeczność Next.js wyszła z inicjatywą i stworzyła bibliotekę NextAuth, która umożliwia nam uwierzytelnianie za pomocą różnych providerów. My będziemy korzystać z GitHuba, ale w bardzo prosty sposób możesz dodać do swojej aplikacji logowanie z pomocą maila, Googla, Facebooka itp.
Konfiguracja NextAuth
Konfiguracja logowania jest bardzo prosta. Zaczynamy od stworzenia specjalnego pliku w katalogu pages/api/auth
o specyficznej nazwie [...nextauth].tsx
. Tworzymy w nim coś podobnego na wzór endpointa z Next API Routes. W wyeksporowanej funkcji NextAuth
podajemy obiekt konfiguracyjny, a w nim deklarujemy rodzaj providera i adaptera. Adapter jest systemem, do którego NextAuth się podłącza, tworzy sesje i konta użytkowników. W naszym przypadku adapterem jest Prisma. NextAuth w pewien sposób definiuje model danych w schemie, tak abyśmy nie musieli nic dodatkowego tworzyć.
import { PrismaClient } from '@prisma/client';
import NextAuth from 'next-auth';
import GitHubProvider from 'next-auth/providers/github';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { getEnv } from '../../../utils/env';
const prisma = new PrismaClient();
export default NextAuth({
providers: [
GitHubProvider({
clientId: getEnv('GITHUB_ID'),
clientSecret: getEnv('GITHUB_SECRET'),
}),
],
adapter: PrismaAdapter(prisma),
secret: getEnv('SECRET'),
});
Sekret to ciąg znaków, który używany jest do hashowania tokenów/szyfrowania ciasteczek.
Jeśli chodzi o stronę serwerową, to nasza konfiguracja gotowa. Aby korzystać z infromacjach o sesji, naszą aplikację frontendową musimy opakować w specjalny provider:
import { useState } from 'react';
import type { AppProps } from 'next/app';
import { Hydrate, QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';
import { SessionProvider } from 'next-auth/react';
import 'tailwindcss/tailwind.css';
import { CartProvider } from '../components/cart/context/cartContext';
export default function App({ Component, pageProps, err }: AppProps & { err: Error }) {
const [queryClient] = useState(() => new QueryClient());
return (
{/* any ❌ */}
<SessionProvider session={pageProps.session}>
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<CartProvider>
<Component {...pageProps} err={err} />
</CartProvider>
</Hydrate>
<ReactQueryDevtools />
</QueryClientProvider>
</SessionProvider>
);
}
To co mi się nie podoba w tym miejscu, to typ any
dla sesji. Jeśli chcielibyśmy to zmienić, to powinniśmy zadeklarować plik next.ds.ts
, w którym poprawimy typy:
import type { NextComponentType, NextPageContext } from 'next';
import type { Session } from 'next-auth';
import type { Router } from 'next/router';
declare module 'next/app' {
type AppProps<P = Record<string, unknown>> = {
Component: NextComponentType<NextPageContext, any, P>;
router: Router;
__N_SSG?: boolean;
__N_SSP?: boolean;
pageProps: P & {
session?: Session;
};
};
}
Identycznie robimy z typami dla sesji użytkownika w pliku next-auth.d.ts
:
import { Session } from 'next-auth';
import type Prisma from '@prisma/client';
declare module 'next-auth' {
interface Session {
user: Prisma.User;
}
}
Pomocnicze hooki i przekierowania
Teraz, gdy nasza konfiguracja jest gotowa, możemy przejść do logowania i odczytywania sesji dla użytkownika. NextAuth udostępnia nam pomocnicze funkcje do logowania, wylogowania i odczytywania sesji. Podobnie jak w przypadku React Query, ja lubię opakować udostępnionego przez bibliotekę hooka useSession()
w dodatkowy wrapper useAuth()
:
import { useMemo } from 'react';
import { useSession, signIn, signOut } from 'next-auth/react';
export const useAuth = () => {
const { data: session, status } = useSession();
return useMemo(
() =>
({
session,
status,
signIn,
signOut,
} as const),
[session, status],
);
};
NextAuth poza systemem do uwierzytelniania, oferuje nam również predefiniowane strony do logowania. W mojej opinii nie wyglądają one zbyt dobrze i nie wpasowują się w styl naszej apki, dlatego dostarczymy nasze customowe rozwiązanie. Tworzymy nową stronę signin
w folderze pages/auth
, w której wykorzystujemy funkcję signIn
z hooka useAuth
:
import { Layout } from '../../components/layout/Layout';
import { SignInButton } from '../../components/auth/SignInButton';
import { useAuth } from './hooks/useAuth';
export default function SignIn() {
const { signIn } = useAuth();
const handleSignIn = () => signIn('github');
return (
<Layout>
<button
onClick={handleSignIn}
className="mt-6 group outline-none relative w-28 flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none"
>
Zaloguj się
</button>
</Layout>
);
}
W pliku konfiguracyjnym biblioteki dajemy znać, że chcemy korzystać z własnoręcznie przygotowanej strony logowania:
export default NextAuth({
providers: [
GitHubProvider({
clientId: getEnv('GITHUB_ID'),
clientSecret: getEnv('GITHUB_SECRET'),
}),
],
pages: {
signIn: '/auth/signin',
},
adapter: PrismaAdapter(prisma),
secret: getEnv('SECRET'),
});
Miejscem, w którym również korzystamy z sesji jest Header
. Wykorzystujemy tutaj dane by wyświetlić zdjęcie bieżącego użytkownika oraz wykonać akcję wylogowania:
export const Header = () => {
const { session, signIn, signOut } = useAuth();
};
Z sesji możemy korzystać na wiele różnych sposobów, jednym z nich jest sprawdzenie, czy użytkownik jest zalogowany i na bazie tego udostępniać mu różne obszary aplikacji. My bądźmy bardzo restrykcyjni, jeśli użytkownik nie będzie zalogowany, to od razu przeniesiemy go na stronę logowania:
export default function Home() {
const { session } = useAuth();
const router = useRouter();
useEffect(() => {
if (!session) {
router.push(getEnv('NEXTAUTH_CALLBACK_URL'));
}
}, [session]);
return (
<Layout>
<Products />
<Checkout />
</Layout>
);
}
Płatności
Za obsługę płatności w naszym sklepie będzie odpowiadało Stripe, czyli jeden z popularniejszych graczy na tym rynku, zobaczmy jak go połączyć z Next.js:
Konfiguracja Stripe
W pierwszej kolejności potrzebujemy stworzyć specjalny endpoint, z którego będziemy zaciągali sesję dla tzw. checkoutu. Po zainicjalizowaniu usługi, deklarujemy endpoint w którym tworzymy sesję za pomocą metody stripe.checkout.sessions.create()
. Przekazujemy do niej obiekt konfiguracyjny, w którym znajdują się również line_items
, czyli przedmioty, które chcemy zakupić:
import { NextApiRequest, NextApiResponse } from 'next';
import { Stripe } from 'stripe';
import { getEnv } from '../../../../utils/env';
const stripe = new Stripe(getEnv('STRIPE_SECRET_KEY'), {
apiVersion: '2020-08-27',
});
export default async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { id } = await stripe.checkout.sessions.create({
mode: 'payment',
submit_type: 'donate',
payment_method_types: ['card'],
success_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/result?session_id={CHECKOUT_SESSION_ID}`,
line_items: req.body,
});
res.status(200).json({ id });
res.end();
} catch {
res.status(500);
}
};
Po stronie klienta, dla uproszczenia, będziemy korzystali z tego jednego endpointu w dwóch różnych miejscach, przy zakupie pojedynczego produktu oraz w koszyku. Zacznijmy od stworzenia funkcji, która będzie otrzymywała sesję Stripe, a następnie na jej bazie przekierowywała do strony checkout'u:
import type Stripe from 'stripe';
import { getEnv } from './env';
import { loadStripe } from '@stripe/stripe-js';
export const redirectToCheckout = async (session: Pick<Stripe.Checkout.Session, 'id'>) => {
const stripe = await loadStripe(getEnv('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY'));
return stripe!.redirectToCheckout({
sessionId: session.id,
});
};
Stripe podobnie jak Prisma udostępnia nam gotowe typy, w tym przypadku korzystamy z typu Stripe.Checkout.Session
, z którego wyciągamy id
sesji. Następnie id
ląduje funkcji redirectToCheckout()
. W związku z tym, że będziemy korzystali z fetchera do zapytania endpointu, potrzebujemy stworzyć scheme:
import * as y from 'yup';
export const stripeSessionSchema: y.SchemaOf<Pick<Stripe.Checkout.Session, 'id'>> = y
.object()
.shape({
id: y.string().required(),
});
Pojedynczy produkt
Z tak przygotowanymi utilsami możemy przejść do kupna pojedynczego produktu. Zacznijmy od stworzenia wrappera na funkcje fetcher
:
import type Prisma from '@prisma/client';
import { fetcher } from '../../../utils/fetcher';
import { stripeSessionSchema } from '../../../utils/stripe';
import { transformProduct } from '../utils/transforms';
export const buyProduct = async (product: Prisma.Product) => {
const stripeItem = transformProduct(product);
return await fetcher(`/api/checkout/products/`, {
method: 'POST',
body: [stripeItem],
schema: stripeSessionSchema,
});
};
Funkcja buyProduct
będzie przyjmowała product, a następnie przekazywała go do kolejnej funkcji pomocniczej transformProduct
. Ta funkcja kryje za sobą zwykłe dopisanie/zmodyfikowanie danych potrzebnych dla samego Stripe. Zmieniamy w tym miejscu strukturę i typ naszego produktu:
export const transformProduct = ({
name,
description,
price,
image,
}: Prisma.Product): Stripe.Checkout.SessionCreateParams.LineItem => ({
name,
description,
amount: price,
currency: 'PLN',
images: [image],
quantity: 1,
});
Tak przygotowaną funkcję przekazujemy do hooka useBuyProduct
. Tutaj ponownie korzystamy z React Query, ale tym razem wykorzystujemy hooka useMutation
. Jeśli korzystałeś wcześniej z GraphQL, to na pewno kojarzysz tę nazwę. Używamy go w sytuacjach, w których potrzebujemy coś wysłać/zaktualizować/usunąć z serwera. Przekazujemy do niego funkcję, do której trafi produkt, który chcemy wysłać do Stripe. Drugim argumentem mutacji jest obiekt, w którym możemy zadeklarować najróżniejsze side-effects. My chcemy zareagować na zdarzenie onSuccess
, w którym odpalimy funkcję redirectToCheckout
:
import { useMutation } from 'react-query';
import type Prisma from '@prisma/client';
import { buyProduct } from '../api/buyProduct';
import { redirectToCheckout } from '../../../utils/stripe';
export const useBuyProduct = () => {
return useMutation((product: Prisma.Product) => buyProduct(product), {
onSuccess: redirectToCheckout,
});
};
Tak przygotowanego hooka wykorzystujemy w komponencie Produkt
w następujący sposób:
export const Product = (product: ProductProps) => {
const { id, image, name, price } = product;
const { mutate } = useBuyProduct();
const buyProduct = () => mutate(product);
...
Hook useMutation
zwraca funkcję mutate
, do której przekazujemy wcześniej zadeklarowany produkt. Jeśli wszystko poszło zgodnie z planem, to po kliknięciu w przycisk powinniśmy zostać przekierowani na stronę zakupu:
Koszyk i checkout
Analogicznie jak w przypadku produktu, tworzymy funkcję, która będzie przesyłała dane do serwera:
import { stripeSessionSchema } from '../../../utils/stripe';
import { transformProduct } from '../../products/utils/transforms';
export const checkoutCart = async (products: Array<Prisma.Product>) => {
const stripeItems = products.map((product) => transformProduct(product));
return await fetcher(`/api/checkout/products/`, {
method: 'POST',
body: stripeItems,
schema: stripeSessionSchema,
});
};
Jedyną różnicą tutaj jest fakt, że w koszyku możemy mieć jednocześnie wiele produktów. Tak przygotowany kawałek kodu przekazujemy do hooka useCheckout()
:
import { useMutation } from 'react-query';
import type Prisma from '@prisma/client';
import { checkoutCart } from '../api/checkoutCart';
import { redirectToCheckout } from '../../../utils/stripe';
export const useCheckout = () => {
return useMutation((products: Array<Prisma.Product>) => checkoutCart(products), {
onSuccess: redirectToCheckout,
});
};
Następnie wykorzystujemy go w komponencie Checkout
:
export const Checkout = () => {
const {
state: { totalPrice, products, isOpen },
dispatch,
} = useCart();
const { mutate } = useCheckout();
const handleOpenMenu = () => dispatch({ type: 'openMenu' });
const handleCloseMenu = () => dispatch({ type: 'closeMenu' });
const handleCheckout = () => mutate(products);
};
Jeśli dodasz teraz kilka produktów do swojego koszyka i sfinalizujesz zakupy, to powinieneś zostać przekierowany do strony płatności:
Obsługa błędów
Nasza aplikacja teoretycznie jest gotowa, ale... Zapomnieliśmy o błędach! Zakładam, że przypomnielibyśmy sobie o nich dopiero na produkcji, gdy jakiś niezadowolony użytkownik zgłosiłby błąd w aplikacji. W apkach produkcyjnych warto przykładać szczególnie dużą uwagę do wszelakich błędów, zarówno tych na frontendzie, ale również tych na backendzie. Fajnie by było również gdzieś zbierać te błędy z produkcji, żeby wiedzieć co potencjalnie możemy naprawiać i w którym miejscu leży błąd. Z pomocą przychodzi serwis Sentry, który oferuje nam zbieranie błędów i zarządzanie nimi z poziomu dashboardu w przeglądarce.
Konfiguracja Sentry
Dzięki paczce @sentry/nextjs
konfiguracja Sentry stała się banalnie prosta. Wystarczy, że stworzymy dwa pliki sentry.client.js
i sentry.server.js
z taką samą zawartością:
import * as Sentry from '@sentry/nextjs';
import { getConfig } from './utils/config';
Sentry.init({
dsn: getConfig('SENTRY_DNS'),
});
Komponent Error
Next.js udostępnia nam customowy komponent Error
, który umieszczamy w folderze pages
w pliku o nazwie _error.tsx
. Ten komponent wykorzystywany jest zarówno po stronie klienta, jak i po stronie serwera, ale tylko i wyłącznie na produkcji. Przychwycone w odpowiednich fazach błędy przekazujemy do Sentry, jeśli chcesz się dowiedzieć więcej o działaniu tego komponetu, to koniecznie zajrzyj do oficialnego przykładu przygotowanego przez Nexta, gdzie twórcy wyjaśniają jego działanie linijka po linijce.
import { ReactElement } from 'react';
import { NextPageContext, NextPage } from 'next';
import NextErrorComponent, { ErrorProps as NextErrorProps } from 'next/error';
import * as Sentry from '@sentry/nextjs';
type ErrorPageProps = {
err: Error;
statusCode: number;
hasGetInitialPropsRun: boolean;
children?: ReactElement;
};
type ErrorProps = {
hasGetInitialPropsRun: boolean;
} & NextErrorProps;
export default function ErrorPage({ statusCode, hasGetInitialPropsRun, err }: ErrorPageProps) {
if (!hasGetInitialPropsRun && err) {
Sentry.captureException(err);
}
return <NextErrorComponent statusCode={statusCode} />;
}
ErrorPage.getInitialProps = async ({ res, err, asPath }: NextPageContext) => {
const errorInitialProps = (await NextErrorComponent.getInitialProps({
res,
err,
} as NextPageContext)) as ErrorProps;
errorInitialProps.hasGetInitialPropsRun = true;
if (err) {
Sentry.captureException(err);
await Sentry.flush(2000);
return errorInitialProps;
}
Sentry.captureException(new Error(`_error.js getInitialProps missing data at path: ${asPath}`));
await Sentry.flush(2000);
return errorInitialProps;
};
Rozbudowa projektu
Tak jak wspominałem Ci na początku, przez formę tego tutoriala musiałem ograniczyć pewne funkcjonalności, ale zachęcam Cię do spróbowania zaimplementowania niektórych ficzerów na które ja nie miałem miejsca, oto kilka moich pomysłów:
- Dodanie ról i autoryzacja. W schemie zadeklarowaliśmy nawet role użytkownika. Możesz nadać użytkownikowi rolę admina i na jej bazie np. zarządzać produktami. Mógłbyś zrobić specjalnego dashboarda do tworzenia/aktualizowania/usuwania produktów z bazy danych.
- Implementacja prawdziwych płatności w Stripe. W naszym koszyku my tak naprawdę tylko przekierowujemy do strony checkout'u w Stripe. Twoim zadaniem byłoby obsłużenie płatności i na bazie tego, czy transakcja się powiodła, np. dodanie kupionego produktu do tabeli użytkownika. Przykłady z bardziej zaawansowanym użyciem Stripe znajdziesz w oficjalnym przykładzie na GitHubie. Słowo klucz: webhook.
- Rozbuowa sklepu, obecnie nasz e-commerce to miniaturowa wersja prawdziwych, dużych sklepów online. Możesz zacząć od np. strony pojedynczego produktu, a skończyć na promocjach, kategoriach produktów, czy zakładce z kupionymi produktami użytkownika, ogranicza Cię tylko Twoja wyobraźnia :)
Podsumowanie
To by było na tyle jeśli chodzi o poradnik z połączenia Next.js z różnymi fullstack'owymi technologiami. Mam nadzieję, że pokazałem Ci możliwości i narzędzia, z których możesz korzystać by tworzyć własne, bardziej rozbudowane aplikacje w Next.
Cały kod dostępny jest w repozytorium na GitHubie. Jeśli Ci się podobało, to nie zapomnij zarzucić ⭐
Do usłyszenia!