Svelte Commerce
A standalone open-source storefront built with SvelteKit, headless across 26 commerce backends. MIT licensed.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证e-commerce-african essentials
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗A subscription-first African grocery platform for the UK diaspora: browse authentic staples, subscribe for member savings, cook with in-app recipes, refer friends for credit, and check out securely with Stripe. Built as a PWA-ready Next.js web app with a mobile-first design: phones get an app-style layout with a bottom tab bar, wider screens get a top menu.
app/globals.css@prisma/adapter-pg driver adapter)| Feature | Where |
|---|---|
| Catalogue: 28 African SKUs (7 baskt private-label), pack sizes, badges, search, category tiles | app/page.tsx, lib/data.ts |
| Product pages: quantity picker, "Use in these recipes" links | app/product/[slug]/page.tsx |
| Accounts: register / sign in / sign out, settings (name, password), forgot password | app/login, app/register, app/account/settings, app/forgot-password, lib/auth.ts |
| Emails (Resend): order confirmation, "on its way" with tracking, password reset | lib/email.ts, lib/email-templates.ts, lib/order-emails.ts |
| Basket: add / update qty / remove, GBP, member pricing, free-delivery progress bar | app/cart, lib/actions/cart.ts |
| Wishlist | app/wishlist, lib/actions/wishlist.ts |
| Membership tiers (Pay As You Go / baskt Basic £4.99-mo / baskt Plus £39.99-yr) via Stripe Billing | app/membership, lib/billing.ts, lib/subscription.ts |
| Member benefits: 5%/10% off, baskt Plus free delivery, premium recipes | lib/pricing.ts |
| Fresh Produce Box: monthly subscription, 3 sizes, Mon–Wed delivery day | app/fresh, lib/freshbox.ts, app/api/fresh/checkout |
| Recipe library with amounts, difficulty, tappable ingredients → basket (premium gated to baskt Plus) | app/recipes, lib/actions/cart.ts |
| Referral engine: per-user code, £5 credit each way on first order | lib/referral.ts, lib/orders.ts, app/account |
| Free delivery over £70 (always for Plus) | lib/pricing.ts |
| Secure checkout with UK delivery address + phone, order records (discount/delivery/credit/address snapshot) | app/api/checkout, app/api/webhooks/stripe |
| Order history with delivery tracking (received → packed → on its way → delivered) | app/orders/page.tsx, components/OrderTracker.tsx |
| Stock control: baskets capped at stock, checkout re-checks, paid orders reduce stock | lib/stock.ts, lib/actions/cart.ts, lib/orders.ts |
| Admin product management (add, edit, archive/restore, delete if never ordered, stock warnings) | app/admin, lib/actions/admin.ts |
| Admin orders: to fulfil / on the way / delivered / unpaid, address and phone, mark packed → shipped (carrier + tracking) → delivered | app/admin/orders, lib/fulfilment.ts |
| Profile (plan, stats, Fresh Box, referrals, credit) | app/account/page.tsx |
One command builds the app, starts Postgres, applies the schema, seeds the catalogue, and serves the app at http://localhost:3000:
docker compose up --build
app waits for the db healthcheck, applies any pending migrations
(prisma migrate deploy), loads the sample catalogue if the shop is empty
(SEED_SAMPLE_DATA=true in docker-compose.yml), then starts Next. If migrations
can't be applied, the app doesn't start..env (AUTH_SECRET, STRIPE_*); compose overrides
DATABASE_URL so the app reaches the db service.docker compose down (add -v to also wipe the database volume).For active development, the local workflow below (host npm run dev + Dockerised
Postgres) gives faster hot-reload. The image keeps full dependencies for a reliable
first run; slimming it with Next output: "standalone" is a future optimization.
DATABASE_URL (pooled, used by the app) and DATABASE_URL_UNPOOLED
(direct, used for migrations).AUTH_SECRET (a new one, e.g.
openssl rand -base64 32), AUTH_TRUST_HOST=true, the STRIPE_* keys and prices,
RESEND_API_KEY, EMAIL_FROM, REQUIRE_EMAIL_CONFIRMATION=false until your Resend
domain is verified, and SEED_SAMPLE_DATA=true if you want the sample catalogue in an
empty shop. Don't set NEXT_PUBLIC_APP_URL or NEXTAUTH_URL: the app
uses the Vercel production domain automatically (set NEXT_PUBLIC_APP_URL only for a
custom domain).https://<your-domain>/api/webhooks/stripe with
the seven events listed under Testing payments, and set its signing secret as
STRIPE_WEBHOOK_SECRET.BLOB_READ_WRITE_TOKEN).scripts/vercel-build.sh) generates the Prisma client,
applies pending migrations, seeds an empty shop if SEED_SAMPLE_DATA=true, then builds.
Preview builds of other branches never touch the database.Functions run in London (vercel.json), next to the database and UK customers. Make
yourself an admin by running npm run make-admin -- [email protected] with DATABASE_URL
set to the production database (e.g. after npx vercel env pull .env.vercel.local).
docker compose up -d db
.env ships with working local defaults for the database and auth. For payments and
memberships, add your Stripe test keys:
STRIPE_SECRET_KEY="sk_test_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..." # from `stripe listen`
STRIPE_PRICE_MONTHLY="price_..." # from `npm run setup-stripe`
STRIPE_PRICE_PLUS="price_..."
STRIPE_PRICE_FRESH_SMALL="price_..."
STRIPE_PRICE_FRESH_MEDIUM="price_..."
STRIPE_PRICE_FRESH_LARGE="price_..."
Values left as the ... placeholders from .env.example are treated as unset, so the
app shows a clear "Stripe is not configured" message instead of calling Stripe.
For emails, add a Resend API key ("Sending access" is enough):
RESEND_API_KEY="re_..."
EMAIL_FROM="baskt <[email protected]>"
Without a key, emails are logged to the terminal instead of sent. Until you verify your own
domain in Resend, [email protected] can only email the address you signed up to
Resend with. New accounts need their confirmation email, so until then either sign up with
that address or set REQUIRE_EMAIL_CONFIRMATION="false".
Also generate a real auth secret with npx auth secret → AUTH_SECRET.
npm run db:deploy # create the tables by applying the migrations in prisma/migrations
npm run db:seed # 28 African SKUs + 4 Nigerian recipes
db:seed adds the sample products and resets them to their defaults; products
added through the admin are left alone.
npm run setup-stripe # creates the baskt Basic/baskt Plus + Fresh Box prices, prints the IDs for .env
npm run dev # http://localhost:3000
The schema lives in prisma/schema.prisma, and every change ships as a versioned
migration in prisma/migrations/ (commit these folders).
prisma/schema.prisma.npm run db:migrate -- --name what_changed creates the migration, applies it to
your local database, then regenerates the Prisma client (Prisma 7's migrate dev
no longer does that itself). Read the generated SQL before committing it.npm run db:deploy).In production, only ever run db:deploy. Never run prisma db push,
prisma migrate dev, or prisma migrate reset against it (they can drop data), and
use a managed Postgres with automatic backups and point-in-time recovery; take a
backup before deploying a migration that changes or removes columns.
Databases created before migrations (with the old db:push and the AfroPantry-era
schema) need a one-time baseline so Prisma knows the tables already exist:
npx prisma migrate resolve --applied 0_init # record the existing tables as migration 0_init
npm run db:deploy # apply the migrations that came after it
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma
# should print "No difference detected"; anything else means the database had drifted
Payments: card details are entered on Stripe's hosted checkout page and never reach this server; Stripe webhooks are signature-checked; prices and totals are always computed on the server.
Attempt limits (lib/rate-limit.ts). Counters live in the RateLimit table, so
limits hold across every server instance:
| Action | Limit |
|---|---|
| Sign-in | 10 per account and 50 per network, per 15 minutes |
| Sign-up | 5 per network per hour; 3 confirmation emails per address per hour |
| Password change | 5 per account per 15 minutes |
| Checkout (basket, membership, Fresh Box) | 10 per account per 10 minutes (HTTP 429) |
"Per network" uses the X-Forwarded-For header set by your host's proxy (Vercel,
Cloudflare, nginx). Run the app behind such a proxy in production; without one the
header can be faked, which is why sign-in is also limited per account.
Browser security headers (next.config.ts): Content-Security-Policy, HSTS,
X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and no
X-Powered-By. The CSP allows inline scripts (no nonces) so pages can still be
cached; switch to nonces (see the Next.js CSP guide) if you need a stricter policy.
Passwords are hashed with bcrypt at cost 12 (older cost-10 hashes keep working).
Sign-ups confirm their email (lib/signup.ts). Signing up stores a pending sign-up
and emails a link valid for 24 hours; the account is only created when that link is
opened and the sign-up password entered, which also signs you in. So nobody can create
an account for an address they can't read, an email scanner that opens links can't
confirm one, and if someone signs up with your address first, their link can't be
finished with your password (the first sign-up confirmed wins). Expired sign-ups are
deleted as new ones arrive. Signing in before confirming says so and sends a fresh link.
REQUIRE_EMAIL_CONFIRMATION="false" creates accounts straight away instead.
Forgot password emails a one-time link that expires in an hour; only a SHA-256 hash of it is stored, asking again cancels the previous link, and the page answers the same way whether or not an account exists (the email is sent after the response, so timing doesn't give it away either). Limited to 3 emails per address and 10 per network an hour.
Sent through Resend (lib/email.ts), with plain HTML and text
versions (lib/email-templates.ts):
Every attempt is recorded in the EmailLog table (never the body), and each order's emails
show on its admin page as Sent, Failed or Not sent. Sending never throws, so a mail problem
can't break checkout or an admin action. Addresses on reserved test domains (example.com,
.test, .invalid, localhost) are never emailed, which keeps test accounts out of your
Resend quota. Before launch, verify your domain and set
EMAIL_FROM to an address on it, e.g. baskt <[email protected]>.
node user.priceCents), currency GBP.lib/pricing.ts) and shown in the
basket. At checkout the discount + credit become a one-time Stripe coupon so the hosted
page shows full prices with savings applied; delivery is a line item.PENDING (checkout open) → PAID, or AWAITING_PAYMENT first for
delayed methods such as bank debits (stock, credit and rewards wait until Stripe confirms
the money), or CANCELLED when the checkout expires or a delayed payment fails. Orders
still pending an hour after checkout started are cancelled when order lists are viewed, as
a backup for a missed "expired" notification. If Stripe later confirms a payment anyway,
the order is revived.lib/orders.ts) is idempotent and shared by the webhook and the
success page: it atomically flips the order to PAID, so even if both run at once, the
basket is cleared, credit deducted, and referral rewards paid exactly once./membership shows the three tiers. Subscribing opens Stripe Checkout in subscription
mode; the webhook (customer.subscription.*) mirrors tier/status into the Subscription
table via lib/subscription.ts. Members manage or cancel through the Stripe Billing Portal
(/api/subscription/portal). Tier is read live from the DB, so benefits apply immediately.
/recipes lists Nigerian recipes; each ingredient that maps to a stocked SKU is tappable
(add one, or "Add all to basket"). Recipes flagged isPremium are gated to baskt Plus.
On Wishlist, "Create a share link" makes a private link (/wishlist/<random token>)
to copy, or share through the phone's share sheet. Anyone with it sees the owner's first
name and the products on the list (nothing else about the account) and can add them to
their own basket; the page asks search engines not to index it. "Stop sharing" deletes
the token, so the old link stops working, and sharing again makes a new one.
Every user gets a unique code; the Profile's "Refer a friend" row shares the link. Sharing …/register?ref=CODE links a new
signup as referred. On that user's first paid order, both parties receive £5 credit
(REFERRAL_REWARD_CENTS), applied automatically on future baskets.
npm run make-admin -- [email protected] # (also accepts multiple emails)
An Admin link appears in the header (desktop) and in the Profile menu; non-admins are
redirected from /admin. Admin has two tabs:
BLOB_READ_WRITE_TOKEN), otherwise to an uploads/ folder on
the server (UPLOAD_DIR to move it; mount it as a volume with Docker). On Vercel the disk
doesn't keep files, so connect a public Blob store there.
Archive hides a product from the shop (search, categories, recipes, baskets) while past
orders keep it, and Restore brings it back. Delete is only offered for products
that have never been ordered.Every admin page and admin query checks for an admin itself; the layout check alone isn't enough because layouts don't re-run on client-side navigation.
Stripe test mode: card 4242 4242 4242 4242, any future expiry, any CVC/postcode.
Forward webhooks to your machine with the Stripe CLI
(recent versions require the event list):
stripe listen --forward-to localhost:3000/api/webhooks/stripe \
--events checkout.session.completed,checkout.session.expired,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted
It prints a whsec_… secret for STRIPE_WEBHOOK_SECRET. If stripe login has expired, add
--api-key "$STRIPE_SECRET_KEY" (after set -a; . ./.env; set +a) instead of logging in again.
Without the CLI, the success page still confirms orders by verifying the session directly.
In the Stripe Dashboard, save the customer portal settings
once, or "Manage subscription" fails.
| Command | What it checks |
|---|---|
npm test | Unit tests: pricing and delivery rules, Fresh Box dates, formatting, input checks. No database needed. |
npm run test:db | Database tests: marking orders paid (including the webhook and success page finishing at the same moment) and attempt limits. |
npm run test:e2e | Browser tests (Playwright): shopping, accounts, admin, security limits and headers. |
E2E_STRIPE=1 npm run test:e2e | Also runs a real purchase, baskt Plus subscription and billing portal visit through Stripe's test mode (needs test keys; deletes its test customer in Stripe afterwards). |
DATABASE_URL with _test added to its
name (or TEST_DATABASE_URL). It's created and migrated automatically, emptied between
tests, and the tests refuse to run against any database whose name doesn't end in _test.npm run dev (or start it) and your installed Google
Chrome. They create e2e+…@example.com accounts and products named E2E …, and delete
them when the run finishes. Run npx playwright test --ui to watch them..github/workflows/ci.yml) runs typecheck, lint, unit
tests and a build, plus the database and browser tests against a throwaway Postgres and
the production build. If browser tests fail, the run page has a downloadable
Playwright report showing what happened.| Command | Description |
|---|---|
npm run dev / build / start | Dev / production build / serve |
npm run db:migrate | Create + apply a migration after editing the schema (development) |
npm run db:deploy | Apply pending migrations (production, staging, fresh setups) |
npm run db:seed / db:studio | Load sample data / Prisma Studio |
npm run typecheck / lint | TypeScript check / ESLint |
npm test / test:db / test:e2e | Unit / database / browser tests (see Testing) |
npm run setup-stripe | Create membership and Fresh Box prices in Stripe |
npm run make-admin -- <email> | Promote a user to admin |
public/manifest.webmanifest).
The original plan envisions a React Native app; the backend here (data model, Stripe,
auth) is API-shaped and reusable if a native client is built later.prisma.config.ts (not the schema) and uses a pg
driver adapter (lib/prisma.ts).Changing or resetting a password signs out other devices. Each sign-in has a random id; anything signed in before the change is ended on its next request, except the sign-in that changed it in Settings. A reset from an email ends them all.