Cultiva Digital
Monte grátis a vitrine das suas plantas e terrários, compartilhe o link no Instagram e receba pedidos pelo WhatsApp.
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A self-serve platform where any food business can sign up, get their own online ordering site at yourdomain.com/{their-slug}, and fully run it themselves — menu, locations, live orders, branding — with no intervention needed from the platform owner.
This started as a single-restaurant demo and was rebuilt into a real multi-tenant product: every business ("tenant") gets its own isolated data, its own admin login, and its own customizable storefront, all served from one shared codebase and database.
src/app/globals.css), not a default template look. Each tenant can override the primary accent color with their own brand color.tenantId directly, so data access is scoped by filtering, not by trusting a chain of joins.sessionStorage.Postgres database. Vercel project → Storage tab → Create Database → Neon/Postgres (auto-adds DATABASE_URL), or bring your own from neon.tech / supabase.com and add DATABASE_URL under Settings → Environment Variables.
SESSION_SECRET environment variable — any long random string (signs the admin session cookie). Generate one with openssl rand -hex 32.
SECRET_ENCRYPTION_KEY — exactly 32 bytes of hex (64 characters), generated the same way: openssl rand -hex 32. Encrypts third-party credentials a tenant pastes into their own settings at rest (currently just their SMS account password — see Customer SMS notifications below). Without this set, saving those settings fails outright rather than storing anything unencrypted.
Vercel Blob storage (needed for logo/menu photo uploads) — Vercel project → Storage tab → Create Database → Blob. This automatically adds a BLOB_READ_WRITE_TOKEN environment variable. Without this, the app still works, but image uploads will fail.
Platform-admin dashboard credentials — three more environment variables, for the owner-only tenant-management dashboard at /platform-admin (see below):
PLATFORM_ADMIN_EMAIL — the email you'll log in with.PLATFORM_ADMIN_PASSWORD — any strong password.PLATFORM_SESSION_SECRET — another long random string, generated the same way as SESSION_SECRET (openssl rand -hex 32) — keep it different from SESSION_SECRET so a leak of one can't be used to forge the other. Until these three are set, /platform-admin has no valid credentials and login always fails.RESEND_API_KEY (needed for email verification / password reset — see below) — create a free account at resend.com and generate an API key. Without a verified sending domain (EMAIL_FROM + a domain added under Resend → Domains), mail can only reach the Resend account's own inbox — fine for testing solo, not for real tenants signing up. Add a custom domain in Resend whenever you're ready and point EMAIL_FROM at it (e.g. Nuya <[email protected]>); every verify/reset link is already built from the request's own origin, so no other code or config needs to change.
PAYMENT_INSTRUCTIONS — the bank-transfer details tenants see on their Billing page: bank, account name and number, and the price. Free text; line breaks are kept, so write it however you'd like it to read (both languages are fine in one block). Until it's set, the Billing page tells tenants to contact you. See Billing below.
Set up the database schema once, from your own computer:
npm install
Create a .env file in the project with:
DATABASE_URL="<same Postgres connection string as Vercel>"
then:
npm run db:setup
This creates the tables and loads one example tenant (see below) so there's something to click through immediately.
Upgrading an existing deployment? After the schema push, run npm run db:grandfather-billing once. Tenants created before billing existed have no trial and were never approved, which would take their sites offline; this approves them with no end date. It's safe to re-run and won't resurrect anyone whose trial genuinely lapsed or whom you revoked.
Deploy. The build runs prisma generate automatically.
/joes-cafe[email protected] / demopass123 at /loginThis is just an example to click through — real tenants are created by anyone visiting /signup.
Anyone visits /signup, picks a business name (which suggests a URL slug, live-checked for availability), a first location name, and an email/password. That creates their account and their site in one step, and logs them straight into their new (empty) admin panel at /{their-slug}/admin. From there they add locations, build their menu (with photos), set time slots, and set their branding — entirely on their own.
yourdomain.com/ → public marketing page
yourdomain.com/login → one global login for all tenants (resolves to the right one)
yourdomain.com/signup → create a new site
yourdomain.com/{slug}/ → that tenant's customer ordering site
yourdomain.com/{slug}/menu → menu + cart
yourdomain.com/{slug}/checkout/ → checkout flow
yourdomain.com/{slug}/orders/ → track an order by number + phone
yourdomain.com/{slug}/admin/ → that tenant's admin panel
A reserved-word list (admin, login, signup, api, etc.) prevents a tenant from ever picking a slug that would collide with a platform route.
Signup fires off a best-effort "verify your email" email (via Resend); a persistent banner in the admin panel offers to resend it until the admin clicks through. This is a nudge, not a gate — signup, login, and the admin panel all work before verifying, since this platform can't yet guarantee delivery to an arbitrary tenant's inbox without a verified sending domain (see the deploy steps above), and gating self-serve signup on that would risk locking new tenants out entirely.
/forgot-password → /reset-password is a standard token-based flow: a random token is generated, only its SHA-256 hash is stored (AuthToken, src/lib/authTokens.ts), and it's single-use and short-lived (24h for verification links, 1h for reset links). Clicking a reset link also settles email verification, since it proves the same thing — that the admin controls that inbox. Not yet built: order-confirmation emails to customers (checkout only collects name + phone, no email field) and billing emails — trial-ending and payment-due nudges appear in the admin panel, but nothing is emailed yet.
This is the part that matters most in a multi-tenant app. Every admin session is signed with the tenant it belongs to; visiting another tenant's /admin URL redirects to login rather than leaking anything. Every API route re-derives the tenant from the authenticated session (never trusts a tenant id from the request) and filters every query by it, and every "fetch by id" admin action (edit a branch, update a menu item, etc.) explicitly checks the fetched record's tenantId before allowing the read/write — so even guessing another tenant's internal ID doesn't work. This was verified directly (created two tenants, confirmed one's session/API calls cannot see or modify the other's branches, menu, or orders).
The checkout success page links straight to a live status page for that order; customers who lose the link can get back to it at /{slug}/orders with just their order number and the phone number they ordered under (the only "ownership" check available without a customer account system). The status page itself polls every few seconds, so it flips from "Being Prepared" to "Completed" automatically as staff work the order — no manual refresh, same live-update approach as everywhere else in the app. The tracking link uses the order's internal id (unguessable), not its order number (short and sequential per tenant, so guessable) — this is what keeps /orders/{id} from being browsable by iterating numbers.
‹ month › picker (orders, revenue, average order value, a daily revenue chart), and that month's best sellers. A new tenant sees an onboarding checklist here first (logo, a menu category, a menu item, real location details in place of the signup placeholders) — it collapses on click and disappears entirely once all four are done, so it never nags an established tenant.At /platform-admin, gated by the PLATFORM_ADMIN_EMAIL/PLATFORM_ADMIN_PASSWORD/PLATFORM_SESSION_SECRET env vars above — completely separate login and session cookie from tenant admin accounts, so a compromised tenant login can never reach it. Lists every tenant (search by name, URL or payment reference; paginated; a "Not yet paid" filter for tenants you haven't approved), with each row showing the owner's email, location count, order count, signup date, and billing status (trial / trial ended / paid / expired, the paid-through date, the payment reference, plus an explicit flag when a site is offline). Actions: approve a tenant or record a renewal once their bank transfer arrives (see "Billing" below), suspend/reactivate a tenant (immediately blocks their admin login and their public site — a suspended owner is told their site is deactivated, rather than that their password is wrong, so they contact you instead of chasing a password reset), or permanently delete a tenant and everything under it (locations, menu, orders, admin accounts) — delete requires typing the tenant's slug to confirm.
Billing is manual, by bank transfer. New tenants get a 14-day free trial, so they can build their menu and see their own site working before paying. After that, their public ordering site stays open only once you've received their payment and approved them in /platform-admin. They can pay early and you can approve them mid-trial; trial time and paid time both count.
NY-7KQ3MP, src/lib/paymentReference.ts). Their Billing page shows it with their business name and your PAYMENT_INSTRUCTIONS, and asks them to put both in the transfer note./platform-admin (the "Not yet paid" filter, or search by the reference), check that the name and reference match, and click Approve. Set the paid-through date (quick picks for +1 month / +3 months / +1 year) or tick No end date for test and complimentary accounts, and optionally add a private note (amount, date, sender).The paid-through date is inclusive and follows the tenant's own timezone, so a tenant paid through the 30th stays live until midnight on the 30th their time. When a trial or paid period runs out, the public ordering site closes, but the admin panel stays open, so they can keep working and see how to pay. The admin panel shows a banner 3 days before a trial ends and 7 days before a paid period ends.
To a diner, a closed storefront shows a neutral "temporarily unavailable" notice, never anything about the restaurant's payment status, and never a 404 (a 404 is reserved for slugs that don't exist and for tenants you have suspended). The one gate everything routes through is isStorefrontOpen() in src/lib/billing.ts.
Stripe is built but dormant. src/lib/stripe.ts, /api/billing/* and /api/stripe/webhook are still here, but nothing in the UI starts a Checkout and the access check ignores the Stripe fields. To switch to self-serve card payments later, set STRIPE_SECRET_KEY / STRIPE_PRICE_ID / STRIPE_WEBHOOK_SECRET, count an ACTIVE/PAST_DUE subscriptionStatus as paid in getBillingState(), and add Subscribe / Manage billing buttons back to the Billing page.
Not yet built: billing emails (nudges are in-app only), a payment history (only the latest paid-through date and a free-text note are kept), and local invoicing — if you're selling into Taiwan, domestic sales require government e-invoices (電子發票), which would need a separate provider.
A tenant can text a customer automatically the moment their order is marked Completed, from Admin → Notifications. This is deliberately per-tenant, not a shared platform account — on a template serving many unrelated businesses, one shared sender would mean every tenant's customers getting texts that don't look like they're from the restaurant they ordered from. Each tenant pastes in their own Mitake (三竹簡訊) account username and password; messages send under that account.
SECRET_ENCRYPTION_KEY above, AES-256-GCM, src/lib/secretCrypto.ts) — Mitake's API needs it in the clear on every send, so it can't be hashed like a login password, but it also isn't stored as plain text./api/tenant/sms/test) lets a tenant confirm their account works before a real order depends on it, rate-limited to 3 per 5 minutes since each call sends a real, billable message.src/lib/sms.ts) is best-effort and non-blocking: it runs via Next's after() once the order-completion request has already responded, and a failed or misconfigured send is logged server-side but never fails the status update itself.Not yet built: LINE as a second channel. Unlike SMS, LINE can't message a phone number directly — it needs the customer's LINE user id, which means adding a "Link your LINE account" step (LINE Login) to checkout first. SMS shipped first because checkout already collects a phone number for every order.
New orders, order status changes, time-slot edits, and menu changes all propagate automatically — no manual refresh anywhere. Every screen quietly re-checks the database every few seconds (~4s on the admin order boards/alert, ~8–10s elsewhere) and pauses while the tab is in the background. This polling approach (rather than a persistent push connection) is what makes it work reliably on Vercel's serverless hosting.
The entire app — marketing site, signup/login, every tenant's customer ordering flow, every tenant's admin panel, and the platform-admin dashboard — is bilingual, defaulting to Traditional Chinese (繁體中文) with an English toggle. A pill switcher (中文 / EN) sits in the top bar/header of every major surface.
This is a hand-rolled dictionary system, not next-intl or Next's built-in i18n routing — the app already has tenant-slug-based routing (/{tenantSlug}/...) and a reserved-slug system, and neither needed disrupting with a locale URL prefix:
locale cookie, 1 year, no URL prefix. Server Components read it via getLocale()/getServerDictionary() (src/lib/i18n/); Client Components read it via the useDictionary() hook (src/components/i18n/LocaleProvider.tsx), which also writes the cookie and calls router.refresh() on change so Server Components pick it up too.src/lib/i18n/dictionaries/{en,zh}/ (e.g. checkout.ts, adminMenu.ts, platformAdmin.ts), merged into a single Dictionary type. The ZH file imports the EN file's inferred type and is checked against it, so a missing translation key is a build error, not a silently-blank string.formatMessage(template, vars) handles {placeholder} substitution — no ICU pluralization library. English strings that need a plural "s" carry their own {plural} token that the caller resolves (count === 1 ? "" : "s"); Chinese translations simply don't reference it.Noto Sans/Serif TC load alongside the existing Latin fonts and sit in the same CSS font-family fallback chain, so Latin and CJK glyphs both render correctly from one stack — no locale-conditional font swapping.data.error strings stay in English. Translating those would mean either localizing every server-side error string (a much bigger, separate effort) or silently swallowing real error detail — neither was worth doing as a side effect of this pass.npm install
npm run db:setup # first time only — creates tables + demo tenant
npm run dev
Requires a .env with DATABASE_URL (Postgres), SESSION_SECRET, and SECRET_ENCRYPTION_KEY; add BLOB_READ_WRITE_TOKEN too if you want image uploads to work locally (from a Vercel Blob store, same as production), and PAYMENT_INSTRUCTIONS if you want the Billing page to show real transfer details.
src/
app/
page.tsx public marketing page
login/, signup/ platform-level auth pages
forgot-password/, reset-password/, verify-email/ password reset + email verification
api/auth/ login, logout, signup, slug-availability, forgot/reset-password, resend-verification
[tenantSlug]/
layout.tsx resolves the tenant from the URL, 404s if missing/inactive,
applies the tenant's brand color
page.tsx customer: location + dining method selection
menu/, checkout/ customer ordering flow
admin/
page.tsx location picker (auto-skips to the branch panel if there's only one)
branches/new/ add a location
settings/ tenant-level settings (name, logo, brand color)
[branchId]/ branch-scoped admin panel
orders/pending/, orders/completed/, menu/, timeslots/, settings/
api/ orders, menu, categories, timeslots, branches, tenant, uploads
components/ ui/ (generic), customer/, admin/, i18n/ (LocaleProvider, LanguageSwitcher)
lib/
tenant.ts cached tenant-by-slug resolver, used by every tenant page
session.ts, adminAuth.ts signed session + the assertTenantOwns() ownership guard
reservedSlugs.ts slug validation + reserved-word list
authTokens.ts hashed, single-use tokens behind email verification + password reset
email/ Resend client, HTML templates, sendVerificationEmail()/sendPasswordResetEmail()
hooks/usePolling.ts shared interval-polling hook behind the live updates
color.ts derives a hover shade from a tenant's brand color
store/orderStore.ts tenant-scoped cart/order-flow state
i18n/ locale cookie, dictionaries (en/zh), getServerDictionary(), formatMessage()
prisma/
schema.prisma data model — Tenant at the top, everything else scoped under it
seed.ts demo tenant (Joe's Cafe) — safe to re-run, won't duplicate data