Praktyczny przewodnik: zbuduj oparty na treści sklep Stripe w Profound CMS — katalog edytowany przez sprzedawcę bez kodu, dwie trasy parametryczne, bezgłowy koszyk i kasę hostowaną przez Stripe.
Gotowy sklep w działaniu — przeglądaj kategorię, otwieraj produkt, dodawaj do koszyka, finalizuj zakup.
Praktyczny przewodnik, w którym budujesz oparty na treści sklep w Profound CMS: katalog produktów (kategorie + pozycje) zamodelowany w CMS-ie, strony listingu i szczegółów z jednej puli tras oraz kasę hostowaną przez Stripe dostarczoną jako bezgłowy komponent.
Trzon to ręcznie pisany Next.js plus panel Profound. Claude Code (za pośrednictwem Profound MCP) wykonuje ciężką pracę w trzech zadaniach — zasila katalog, spina system projektowy i tworzy komponenty witryny (w tym bezgłowy koszyk). Trzy części: Setup, Build, Production.
Płatności w jednej linijce. Korzystamy ze Stripe-hosted Checkout: kupujący płaci na stronie Stripe, nie na Twojej. Twoja aplikacja robi tylko dwie rzeczy po stronie serwera — tworzy sesję Checkout i weryfikuje jeden webhook. Bez pól karty, bez Stripe Elements, bez ciężaru PCI.
/products/{item_code}, /categories/{category_code}) plus statyczna /cart, wszystkie oparte na jednym zestawie komponentów.useCart) oraz kasę hostowaną przez Stripe, przy czym cena jest zawsze rozwiązywana po stronie serwera z identyfikatora Stripe Price.curl -fsSL https://bun.sh/install | bashstripe login) do lokalnych webhooków.gh) i konto Vercel połączone z GitHubem.Profound rozdziela treść od renderowania:
category, item); komponent oznaczony tagiem UI Element można umieszczać na stronie (nav, product_grid, …).meta.params.* w CEL, routeParams w React).cms-renderer; Stripe jest dodany jako zwykłe trasy API.Jedyna zasada, która kształtuje budowę: CEL wiąże tylko pola typu string/number. Więc skalarną oprawę (marka w nawigacji, stopka, nagłówki) spinamy z CEL, podczas gdy wszystko, co bogatsze lub kolekcja (siatka produktów, galeria zdjęć, rich text) jest pobierane wewnątrz komponentu React na podstawie parametru trasy. A Stripe jest źródłem prawdy o cenach — price w CMS-ie służy tylko do wyświetlania; obciążenie zawsze rozwiązuje się po stronie serwera na podstawie identyfikatora Stripe Price.
Stan końcowy: niewielki opublikowany katalog, aplikacja podłączona do jego odczytu, Stripe zainstalowany, design wpięty — nic jeszcze nie jest renderowane.
Zarejestruj się w Profound (uwierzytelnianie WorkOS). Utwórz witrynę o nazwie store, a następnie skopiuj jej identyfikator witryny (UUID w adresie panelu) oraz klucz API w trybie odczytu (Deployments → Create API key). Aplikacja tylko odczytuje; późniejsze zasilanie katalogu przechodzi przez MCP, które uwierzytelnia się osobno.
bunx create-profound-next store
cd store
bun add stripe
Szablon to projekt Next.js App Router wstępnie okablowany dla Profound (cms-renderer SDK, trasa catch-all, skrypt generate-schemas, <Refresher>). Nie zawiera stylowania.
bun add stripe pobiera serwerowe SDK — jedyną zależność płatniczą, jakiej potrzebuje hostowana kasa.
Dodaj swoje wartości do .env.local:
# CMS
PROFOUND_API_KEY=<your read key>
NEXT_PUBLIC_PROFOUND_WEBSITE_ID=<your website id>
NEXT_PUBLIC_CMS_API_URL=https://cms.dev.tryprofound.com
NEXT_PUBLIC_BUNNY_CDN_URL=https://cms-profound.b-cdn.net # serves CMS-hosted images
# Stripe
STRIPE_SECRET_KEY=sk_test_... # test key here; swap to your live key when you go live
STRIPE_WEBHOOK_SECRET=whsec_... # filled in Build step 4
NEXT_PUBLIC_SITE_URL=http://localhost:3000
Pobierz STRIPE_SECRET_KEY ze Stripe → Developers → API keys. Używamy klucza testowego
(sk_test_…), więc budowa nigdy nie przenosi prawdziwych pieniędzy; kiedy będziesz gotów przyjmować realne płatności, przełącz się na klucz produkcyjny. Uruchom bun dev i otwórz localhost:3000 — startowy widok się renderuje.
Hostowana kasa przekierowuje przeglądarkę na adres Stripe, więc serwerowy klucz tajny to wszystko, czego Stripe potrzebuje — bez klucza publicznego, bez klientskiego SDK Stripe.
category, product_image i itemUtwórz trzy Custom Components (Components → Create new component) — źródło danych, więc bez tagu UI Element. Ustaw każdy jako Active.
CMS nie ma pola „tablica obrazów”, więc galeria to tablica referencji do niewielkiego komponentu product_image. Utwórz category i product_image (i ustaw je jako Active) przed item — pole referencyjne może wskazywać tylko na aktywne komponenty.
category — name (Text), code (Text, Route Slug), description (Rich text), heroImage (Image)product_image — image (Image)item — name (Text), code (Text, Route Slug), description (Rich text), images (tablica referencji → product_image), price (Number, grosze — tylko do wyświetlania), currency (Select, usd), stripePriceId (Text), category (Reference → category), active (Boolean)Zostaw wszystkie pola jako opcjonalne. Panel administracyjny zamienia nazwy pól na lower_snake_case („Stripe Price Id” →
stripe_price_id) — na nich opiera się Twój kod, więc odczytaj prawdziwe nazwy przez generate-schemas. Trasowy uchwyt nazywamy code (nie slug): to Route Slug i klucz do czystego documents.getByCode później.
Komponent item — code jako Route Slug, images jako referencje do product_image, plus stripePriceId i referencja category.
bun run generate-schemas
Zapisuje schematy Zod + typy do generated/cms-schemas.ts (categorySchema/Category,
itemSchema/Item). Działa też jako kontrola połączenia — błędne dane logowania spowodują tutaj błąd.
Zainstaluj i uwierzytelnij MCP raz:
claude mcp add --transport http Profound http://107.21.107.99:8081/mcp
Uruchom mcp__Profound__authenticate, przejdź przez proces WorkOS, a następnie poproś Claude'a:
Wygeneruj niewielki katalog e-commerce dla sklepu o nazwie Edison's Inventions — trzy kategorie i następujące produkty, z krótkim, osadzonym w epoce
descriptiondla każdego,pricew groszach,currency: "usd"orazactive: true:
- Lighting & Power (
code: lighting): Incandescent Lightbulb (incandescent-lightbulb, 24 $), Electric Dynamo (electric-dynamo, 890 $), Electric Pen (electric-pen, 49 $)- Sound Recording (
code: sound): Tinfoil Phonograph (tinfoil-phonograph, 249 $), Carbon Microphone (carbon-microphone, 59 $), Dictaphone (dictaphone, 179 $)- Motion Pictures (
code: motion): Kinetoscope (kinetoscope, 399 $), Kinetograph Camera (kinetograph, 549 $)Każda kategoria potrzebuje
namei tego z małych litercode; każda pozycja potrzebujename, tego z małych litercode,description,price(w centach),currencyiactive. Zapisz to wdata/catalog.jsoni zweryfikuj względem naszych komponentówcategoryiitem. Następnie użyj Profound MCP, aby utworzyć każdy dokument jako opublikowany: najpierw twórz kategorie, zapisz ich identyfikatory, potem twórz produkty z ustawionymcategoryjako referencję —{ "_type": "reference", "_ref": "<category-id>", "_schema": "category" }. Pozycje wstawiaj równolegle.stripePriceIdpozostaw puste.
Claude zapisuje data/catalog.json, waliduje go i wysyła równoległe wywołania create_document (status: "published"). Zasil kategorie przed produktami, aby referencje wskazywały na istniejące identyfikatory.
Trzy zasilone kategorie, opublikowane i Live.
Osiem zasilonych produktów, każdy powiązany z kategorią.
Katalog jest w CMS-ie; teraz nadaj kilku produktom prawdziwą cenę Stripe — zadanie sprzedawcy, wykonane w dwóch panelach, bez kodu:
price_…).stripePriceId, zapisz.CMS przechowuje katalog; Stripe przechowuje właściwą cenę; połączeniem jest jeden ciąg, który sprzedawca wkleja. (Wolisz automatyzację? Oficjalny Stripe MCP może utworzyć Products/Prices za Ciebie — skopiuj zwrócone identyfikatory w ten sam sposób.)
Szablon nie ma stylowania. Umieść DESIGN.md (blok @theme Tailwinda v4 + tokeny) w katalogu głównym projektu — własny albo pobrany z refero.design.
Następnie poproś Claude'a, ograniczając go tylko do stylowania:
Przeczytaj plik design, który właśnie dodałem. Skonfiguruj Tailwinda, jeśli trzeba, a następnie podepnij motyw i fonty, aby stylowanie działało. Użyj
next/fontdo fontów — nie ładuj ich z Google w czasie rzeczywistym. Tylko stylowanie — nie buduj jeszcze żadnych stron ani komponentów.
Sprawdź, czy src/app/globals.css ma @import "tailwindcss"; + blok @theme, oraz czy localhost:3000 prezentuje tokeny. Trzymaj prompt krótko (przy otwartym pytaniu agent potrafi wystawić całą stronę główną) i ładuj fonty przez next/font, nigdy importem Google w runtime.
Opcjonalne — do działającej kasy dojdziesz bez zdjęć. Aby je dodać: utwórz dokument product_image na każdy obraz (wgraj plik do pola image), a następnie zreferuj je w tablicy images produktu. Użyj własnych zdjęć albo wygeneruj spójny zestaw modelem graficznym (niech Claude wyprowadzi spójne prompty z DESIGN.md i zablokuj jedno --sref w Midjourney, by każde ujęcie miało tę samą stylistykę).
Samodzielny
cms-renderernie ma helpera do URL obrazów, więc dodajbuildAssetUrldosrc/lib/image.ts(~40 linijek) — poprzedzaNEXT_PUBLIC_BUNNY_CDN_URLi dodaje rozszerzenie. Komponenty z kroku 3 części Build korzystają z niego.
Zbuduj warstwę renderowania i kasę, kończąc na realnym zakupie w trybie testowym.
Pięć komponentów, każdy Active i z tagiem UI Element (Settings → Tags), bez Route Slug:
nav → brand · product_grid → heading · product_detail → heading ·
cart_summary → heading · footer → text (wszystko Text)Tag UI Element sprawia, że komponent pojawia się na liście Add UI Element w Page Builderze — samo Active nie wystarczy. Każde pole jest skalarem (tym, który CEL wiąże); właściwe dane katalogu nie są tutaj polem — ProductGrid/ProductDetail pobierają je według parametru trasy (krok 3).
bun run generate-schemas
Jedno polecenie tworzy helper do odczytu, pięć komponentów, koszyk i rejestr:
Zbuduj naszą witrynę w
src/, używając Profoundcms-rendererSDK.
src/lib/catalog.ts— serwerowy czytnik CMS. Utwórz klienta za pomocągetCmsClient({ cmsUrl: process.env.NEXT_PUBLIC_CMS_API_URL!, apiKey: process.env.PROFOUND_API_KEY, websiteId: process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID! })zcms-renderer/lib/cms-api. WyeksportujgetItemByCode(code)→cms.documents.getByCode.query({ websiteId, schemaName: "item", code }), zwracającyres.document.published_content. WyeksportujlistItems(categoryCode?)→cms.documents.list.query({ websiteId, schemaName: "item", status: "published", limit: 100 }), mapujres.documentsdo.published_content, filtrujactive !== false, a jeśli podanocategoryCode, zachowaj pozycje, którychcategory._refrówna się identyfikatorowi kategorii. WyeksportujresolveImages(refs)rozwiązujący każdą referencjęitem.imagesprzezcms.documents.get.query({ websiteId, id: ref._ref })i zamieniający jej pole obrazu na URL przy pomocy zaimplementowanegobuildAssetUrl(część 1, krok 8).
src/components/— pięć komponentów elementów UI zarejestrowanych w rejestrze trasy catch-all po nazwie komponentu, snake_case jak w panelu:{ nav, product_grid, product_detail, cart_summary, footer }.NaviFooterczytają swoje pola skalara z propówcontent(typBlockComponentProps<T>zcms-renderer/lib/types).ProductGridiProductDetailto asynchroniczne komponenty serwerowe odczytującerouteParamsi pobierające dane zcatalog.ts:routeParams.<param>to{ value, … }— odczytuj.value, więcProductGridwywołujelistItems(routeParams.category_code?.value)(karty linkują do/products/{code}) iProductDetailwywołujegetItemByCode(routeParams.item_code?.value)(galeria przezresolveImages, opis rich-text, cena, Dodaj do koszyka).CartSummaryrenderuje koszyk zuseCartz przyciskiem Zapłać. TrzymajformatPricew czystymsrc/lib/format.ts, żeby komponenty klienckie nie importowały serwerowegocatalog.ts.
src/components/AddToCartButton.tsx— przycisk"use client"przyjmujący{ code, name, priceLabel }i wywołującyuseCart().addItem({ code, name, priceLabel, quantity: 1 }). Użyj go wProductDetail.
src/lib/useCart.ts— bezgłowy koszyk: pozycje{ code, name, priceLabel, quantity }w stanie, utrwalane wlocalStorage, wystawiającyaddItem/removeItem/updateQty/subtotalorazcheckout()wysyłający POST{ lines: [{ code, quantity }] }(tylko kody i ilości — nigdy ceny) na/api/stripe/checkout, po czym przekierowujący do zwróconegourl.Styluj wszystko naszym systemem projektowym, jako własne komponenty — nie kopiuj układu strony źródłowej.
Trzy rzeczy warte zapamiętania po wykonaniu polecenia:
content ({ content }: BlockComponentProps<T>) — zdekonstruowanie pól jako propów najwyższego poziomu spowoduje pusty rendering. Dane katalogu pochodzą z routeParams + pobrania w catalog.ts, bo CEL nie wiąże list ani galerii. Koszyk niesie kody pozycji, nigdy ceny.routeParams.<param> to { value, schemaName, document } — czytaj .value. Odczyty zwracają
published_content, nie .content. Klucze rejestru są snake_case, tak jak w panelu.@types/react/@types/react-dom do v19 — szablon dostarcza v18, co łamie bloki asynchronicznych komponentów serwerowych na React 19.Trzy krótkie pliki serwerowe — jedyny kod płatności w aplikacji. Ponownie używają getItemByCode, więc obciążenie rozwiązuje się po stronie serwera.
src/lib/stripe.ts:
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
src/app/api/stripe/checkout/route.ts — rozwiąż każdy produkt z CMS-u, obciąż ceną Stripe:
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getItemByCode } from "@/lib/catalog"; // server-side, read-tier key
export async function POST(req: Request) {
const { lines } = await req.json(); // [{ code, quantity }] — no prices from the client
const line_items = await Promise.all(
lines.map(async ({ code, quantity }: { code: string; quantity: number }) => {
const item = await getItemByCode(code); // server resolves from the CMS
return { price: item!.stripe_price_id, quantity }; // price from CMS, never client
})
);
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items,
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/cart?status=success`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/cart?status=cancelled`,
});
return NextResponse.json({ url: session.url }); // client redirects here
}
src/app/api/stripe/webhook/route.ts — zaufany sygnał realizacji zamówienia:
import { stripe } from "@/lib/stripe";
export async function POST(req: Request) {
const body = await req.text(); // RAW body — required for signature verification
const sig = req.headers.get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Bad signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
// fulfill: record the order / send a receipt.
}
return new Response(null, { status: 200 });
}
Wymagana poprawka szablonu:
src/proxy.tsprzekierowuje każde/api/*do CMS-u, więc Twoje trasy Stripe nigdy się nie wykonują. Przepuść je najpierw:import { createCmsProxy } from "cms-renderer/lib/proxy"; import { NextResponse, type NextRequest } from "next/server"; import { cmsConfig } from "@/lib/cms-config"; const cmsProxy = createCmsProxy({ upstream: cmsConfig.cmsUrl }); const LOCAL_API_PREFIXES = ["/api/stripe"]; export const proxy = async (request: NextRequest) => { if (LOCAL_API_PREFIXES.some((p) => request.nextUrl.pathname.startsWith(p))) { return NextResponse.next(); // handle locally } return cmsProxy(request as unknown as Parameters<typeof cmsProxy>[0]); }; // keep the scaffold's `export const config = { matcher: [...] }` unchangedWeryfikacja:
curl -X POST localhost:3000/api/stripe/webhook -d xzwracaBad signature.
Uruchom Stripe CLI do lokalnych webhooków:
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# copy the whsec_... into STRIPE_WEBHOOK_SECRET, restart bun dev
whsec_… jest per sesję. O bezpieczeństwo dbają dwie zasady: checkout ponownie wyprowadza cenę z CMS-u po code (manipulacja koszykiem nie zmieni jej), a webhook weryfikuje sygnaturę względem surowego ciała.
Panel → Pages → Create page, trzy razy. Przypisz każdy parametr do komponentu (pole slug code):
/products/{item_code} → item/categories/{category_code} → category/cart — statyczna strona (wpisz dosłownie /cart, nie /{cart})Dla każdej trasy: Page Builder → Add UI Element → Custom → dodaj komponenty w kolejności, wypełnij pola skalarne (wartość statyczna lub CEL), Publish.
/products/{item_code}: nav, product_detail, footer/categories/{category_code}: nav, product_grid, footer/cart: nav, cart_summary, footerUstaw nav.brand i footer.text na statyczne ciągi; nagłówki na statyczne etykiety.
Page Builder na trasie kategorii — zaznaczony element product_grid, nagłówek powiązany przez CEL.
Page Builder na trasie produktu — element product_detail na powiązaniu produktu.
Pułapka parametrycznego Page Buildera: na dwóch trasach parametrycznych dodane elementy UI się nie zapisują (bloki się osierocają i strona renderuje się pusta). Do czasu naprawy, powiąż
block_idstych stron bezpośrednio przez Profound MCPupdate_page, a następnie opublikuj. (Statyczna/cartpodpina się normalnie.) Z tego samego powoduProductGridwyprowadza nagłówek z kategorii, którą pobiera, zamiast przez CEL.
/categories/lighting → siatka. Kliknij produkt → szczegóły + Dodaj do koszyka. /cart → Zapłać./cart?status=success, a stripe listen pokaże
checkout.session.completed.Renderowana strona produktu — galeria, cena i przycisk Dodaj do koszyka.
Koszyk — pozycje i pojedynczy przycisk Pay-with-Stripe.
Tylko produkty z ceną są możliwe do zakupu — kup jeden z ~3, którym nadałeś ceny w kroku 6.
Opcjonalnie — umiędzynarodowienie. Przetłumacz każdy komponent (wszystkie 35 języków naraz), dodaj segment
/{language}/…odwzorowany na wbudowany komponent systemowylanguagei przełącz pola powiązane przez CEL nadocuments.translated. Zobacz poradnik dotyczący katalogu lotnisk, część 2, krok 7.
git init && git add -A && git commit -m "Stripe storefront"
gh repo create store --private --source=. --push # --public is fine too
Polecenie build:
generated/cms-schemas.tsjest w.gitignore, więc przypnij build, aby wygenerował go ponownie — dodajvercel.json:{ "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "bun run generate-schemas && next build" }
W Vercel: Add New → Project, zaimportuj store i dodaj zmienne środowiskowe — PROFOUND_API_KEY,
NEXT_PUBLIC_PROFOUND_WEBSITE_ID, NEXT_PUBLIC_CMS_API_URL, NEXT_PUBLIC_BUNNY_CDN_URL,
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET (wartość dla wersji wdrożonej, poniżej) oraz
NEXT_PUBLIC_SITE_URL (Twój adres produkcyjny). Wdróż.
Następnie podepnij webhook wdrożony (sekret stripe listen był tylko lokalnie): Stripe
→ Developers → Webhooks → + Add endpoint → https://<prod>/api/stripe/webhook, zdarzenie
checkout.session.completed. Skopiuj jego whsec_… do Vercel i wdroż ponownie.
Brakująca zmienna środowiskowa = „działa lokalnie, puste w produkcji” — najczęstsza pułapka wdrożeniowa. Wdrażamy tutaj z kluczami testowymi; kiedy będziesz gotów przyjmować realne płatności, przełącz STRIPE_SECRET_KEY oraz sekret webhooka na wartości produkcyjne.
Oba elementy dostarcza szablon.
<Refresher> aktualizuje stronę, którą edytor przegląda, gdy zapisuje w panelu — bez redeployu. (To podgląd dla edytora; odwiedzający widzą opublikowaną treść po zwykłej rewaluacji.)?edit_mode=true do dowolnego URL-a, by zobaczyć nakładki edycyjne. Publiczni odwiedzający oglądają czystą stronę.Dodaj trasę preview, której brakuje w szablonie. Panel ładuje swoje iframe podglądu pod
/cms-preview_<path>; bez tej trasy każdy podgląd kończy się 404. Dodaj ją:// src/app/cms-preview_/[...slug]/page.tsx import { ParametricRoutePreviewPage } from "cms-renderer/lib/renderer"; import { registry } from "../../registry"; // extract your registry to a shared module export default async function Page({ params, searchParams }) { const { slug } = await params; const PreviewPage = ParametricRoutePreviewPage as any; // async RSC; React 19 types return <PreviewPage registry={registry} apiKey={process.env.PROFOUND_API_KEY ?? ""} websiteId={process.env.NEXT_PUBLIC_PROFOUND_WEBSITE_ID ?? ""} cmsUrl={process.env.NEXT_PUBLIC_CMS_API_URL ?? "https://cms.dev.tryprofound.com"} params={Promise.resolve({ slug })} searchParams={searchParams} />; }Dodaj również
src/app/cms-preview_/page.tsx(to samo,slug: []) dla korzenia segmentu.
Sklep Stripe oparty na treściach: katalog w CMS-ie, strony listingu + szczegółów z jednej puli tras i działająca kasa hostowana. AI zasiliło katalog, spieło design i napisało czytnik katalogu + komponenty + bezgłowy koszyk; Ty przygotowałeś komponenty, podlinkowałeś ceny Stripe, ustawiłeś trzy trasy, chrom CEL oraz trzy krótkie pliki Stripe. CEL wiąże oprawę; komponenty pobierają katalog. A Stripe pozostał zwięzły — jedno wywołanie sessions.create i jeden podpisany webhook, z kupującym płacącym na stronie Stripe.