ESP32-S3 Bench Guide
ESP32-S3 N16R8 bench guide: live pinout, lessons, simulators and the bench log.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證MentorsMD Marketplace
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗This is now a full running app: real pages under app/, backed by the
API routes below and a Postgres database via Prisma. Buyers self-register,
sellers can only be created by redeeming an admin-issued invite link.
POST /api/invites with a seller's email.
It creates a random code tied to that exact email, expiring in 7 days.POST /api/signup/seller
with the code, their email, a password, and their profile info.PENDING, has it not expired, and does the email match? Only if all four
pass does it create a SELLER-role user — inside a database transaction
that also marks the invite REDEEMED, so it can never be used twice.There is no other code path that creates a seller. A buyer calling the
regular signup endpoint always gets role: BUYER, full stop.
npm install
cp .env.example .env # then fill in DATABASE_URL and NEXTAUTH_SECRET
npx prisma migrate dev --name init
npm run dev
You'll need a Postgres database — Railway, Supabase, and Neon all have free tiers that work fine for development.
There's no signup route for ADMIN on purpose — you don't want that
self-serve either. After your first migrate dev, create yourself an
admin directly:
npx prisma studio
Open the User table and add a row with role = ADMIN and a
passwordHash generated by running this once in a node REPL:
require("bcrypt").hashSync("your-password-here", 12);
This is how buyers pay through the site and sellers get paid out, without you ever touching a bank transfer yourself:
POST /api/stripe/connect creates a Stripe
Express account for the seller (first time only) and returns a
Stripe-hosted onboarding URL. Stripe collects their bank details, tax
info, and identity verification directly - it never touches your
database.lib/hourCheckout.ts: MentorsMD credit is applied
first, then a Stripe Checkout Session for the rest. The charge goes to
your Stripe balance, not the mentor's (separate charges and
transfers). The old POST /api/checkout (fixed price + due date) now
answers 410.POST /api/webhooks/stripe listens for
checkout.session.completed and fulfils the order or top-up once
(IN_ESCROW = active package). Always trust this over the success-page
redirect, since a user could hit that URL without actually paying./api/cron/monthly-payouts
(lib/payouts.ts) sends each mentor ONE Stripe Transfer for everything
not yet paid: hours logged since their last payout plus the unused
hours of packages approved by then, at 70%. The 30% platform fee just
stays in your Stripe balance. Adjust the cut in lib/fee.ts
(PLATFORM_FEE_PERCENT). Details in "Oct 2026 hourly redesign" at the
end of this file.For local testing, use the Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
It'll print a whsec_... value to put in your .env.
Included:
prisma/schema.prisma — full data model (users, invites, gigs, orders, messages)lib/auth.ts — NextAuth credentials login, role attached to session/api/invites — admin creates/lists invite codes/api/signup/buyer — open signup/api/signup/seller — invite-gated signup (the important one)/api/gigs — public read, seller-only create/api/gigs/[id] — edit/delete, ownership-checked so one seller can't touch another's packages/api/stripe/connect — seller payout onboardinglib/hourCheckout.ts — student payment (credit first, then Stripe), held on the platform/api/webhooks/stripe — confirms payment before trusting itlib/payouts.ts + /api/cron/monthly-payouts — the monthly payout (70% to the mentor)/api/admin/earnings — Admin -> Orders & payouts totals: sales, credit, collected, fee, paid out, not yet paid out/api/orders — the current user's bookings (buyer view) or orders placed with them (seller view)/api/conversations — get-or-create a buyer/seller thread, and list your own threads/api/conversations/[id]/messages — send/list messages, restricted to the two participants/api/calls/[id]/join — gives the student or mentor their own token for the call's private Daily.co room (from 10 min before until 30 min after)Replaced (Oct 2026 hourly redesign). Kept for history: packages no longer have due dates, the 96-hour auto-release and its cron are gone, and mentors are paid monthly. See "Oct 2026 hourly redesign" at the end of this file.
The due date isn't free-standing - it's gated by an actual conversation:
/api/checkout refuses to create an order unless the buyer has sent at
least one message to the seller AND the seller has replied at least
once, in their shared Conversation. This stops a buyer from locking in
a deadline before confirming the coach can realistically hit it./api/conversations/eligibility?sellerId=... is what the frontend
calls to decide whether to show the due-date picker as enabled or
disabled, mirroring the same rule.IN_ESCROW, messaging and video calls both stay fully
open on that same Conversation - nothing in the schema or routes locks
the thread while work is in progress.You'll create one Stripe account for the business (linked to your business bank account). Buyer payments land in that account's Stripe balance, not your bank account directly:
So the only "manual" step in the whole system is deciding, once, how often you want Stripe to sweep your balance to your bank account. Everything else - collecting payment, holding it in escrow, splitting the cut, and paying the seller - is automatic once these routes are wired up.
Built, including the frontend:
app/: home/browse, login, buyer signup, coach onboarding
(from the invite email), coach profile with the message-gated booking
flow, checkout, orders list, order detail (live messaging + video +
release), seller dashboard, seller payouts, and the admin pagecomponents/TopNav.tsx - adapts what it shows based on session/rolelib/hooks/useConversation.ts - the real-time messaging hook, used on
both the profile page and the order detail pageNot yet built (natural next steps):
stripe.refunds.create against the original PaymentIntent instead of releasing)<iframe> instead of opening it in a new tabCreating an invite (POST /api/invites) now emails the seller directly
via Resend, with a link like:
https://yoursite.com/onboard-coach?code=A1B2C3D4&[email protected]
They click it, land on a signup form with their email pre-filled, set a
password, and they're in. Nobody has to type or copy a code - the code is
just riding along in the URL. If an email bounces or gets lost,
POST /api/invites/[id]/resend sends it again without creating a new
invite.
Messages arrive the moment they're sent - no refresh. Replaced Pusher in Oct 2026. The pieces:
lib/realtime.ts (server) - sendLive(userIds, event, payload) sends an
event to each person's own private topic (live:user:<id>, or
preview:user:<id> on preview sites) through Realtime's REST broadcast
endpoint, using SUPABASE_SERVICE_ROLE_KEY. Never throws./api/realtime/token - gives a signed-in person a one-hour pass (a JWT
signed with SUPABASE_JWT_SECRET, sub = their user id). A Realtime
authorization rule on realtime.messages
(prisma/supabase-storage-realtime-2026-10.sql) only lets that pass
receive its own topic; browsers can never send.lib/realtimeClient.ts (browser) - one shared connection per tab:
onLive((event, payload) => ...). Events: message
{ conversationId, message } and dispute-message { orderId, message }./api/conversations/[id]/messages - saves the message, then sends it to
both people; /api/orders/[id]/dispute-messages sends to the student,
the mentor and every admin with the Disputes permission.lib/hooks/useConversation.ts / useDisputeThread.ts - load history,
then stay live. The top bar's unread badge and the inbox list refresh
on new messages too. Without Supabase (keys missing, or Realtime down)
threads check for new messages every 20 seconds instead.Neon is the best default here. It's serverless Postgres (scales to zero when idle, so you're not paying for a database sitting unused), has a generous free tier, and its "branching" feature lets you spin up a full copy of your database for testing changes without touching production - genuinely useful once you're iterating on the schema. One thing worth knowing: Neon was acquired by Databricks in 2025, which hasn't changed the product but is worth keeping an eye on if long-term platform independence matters to you.
Supabase is the other strong option, especially if you'd rather not hand-roll some of what we built ourselves - it bundles Postgres with built-in auth, file storage, and realtime subscriptions, so you could potentially replace NextAuth with Supabase's own tools later. (MentorsMD moved its file storage and live messages to Supabase in Oct 2026.) More convenient if you want fewer moving parts; less convenient if you want to swap pieces out independently.
Railway is the simplest choice if you want your database and your app hosted in the same place with one dashboard - less "best in class" than Neon or Supabase individually, but very easy to reason about.
For this project, Neon is the right starting point: it's the most directly compatible with the Prisma-based approach already in this repo.
Vercel remains a fine default (it's built by the Next.js team, and nothing here requires anything Vercel-specific). If you want options:
None of these require changing the code in this repo - swapping hosts is a deployment decision, not an application one.
http://localhost:3000/api/auth/callback/googlehttps://yourdomain.com/api/auth/callback/google.env as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.npx prisma migrate dev --name add_google_auth to add the two new tables Google sign-in needs (Account and Session).That's it - the "Continue with Google" button on the login and signup pages will start working.
Every Google sign-in creates (or logs into) a User row through the same
code path, and that row's role defaults to BUYER unless something
explicitly sets it otherwise. Sellers are only ever created by the
invite-gated /api/signup/seller route, which never touches Google
sign-in at all. On top of that, lib/auth.ts has an explicit check that
refuses a Google sign-in outright if the email already belongs to a
SELLER or ADMIN account - so even someone who already has a coach account
can't use "Continue with Google" as a side door.
/ - the marketing homepage: your own copy about the company, the main
search bar, and a "Browse all coaches" button.
Edit app/page.tsx directly to change the wording - it's plain text
and JSX, nothing dynamic to break./coaches - the actual list of every coach, with its own search bar
right above the results. This is what both search bars (homepage and
top nav) send people to.A lot landed at once - here's the map of what's where.
Reviews - a buyer can review an order once it's RELEASED, one review
per order (enforced at the database level via a unique constraint on
orderId). Shown on the coach's profile page with an average rating.
Categories & filters - every gig has a category (essay review, mock
interview, application strategy, tutoring, other). /coaches has real
filter and sort controls (components/CoachFilters.tsx) that rebuild the
URL's query string, so results are shareable/bookmarkable, not just
client-side hidden/shown.
File attachments - Supabase Storage (replaced Vercel Blob in Oct 2026,
see lib/storage.ts). Private files (message attachments, deliveries,
résumés) live in the private-files bucket; the database stores their
path, and downloads go through routes that check access and then send a
5-minute link (/api/files/message/[id], /api/files/delivery/[id]/[index],
/api/admin/applications/[id]/resume). Profile and website photos live in
the public public-media bucket. The browser uploads straight to storage
with a one-time link from our server (lib/upload.ts; /api/upload for
attachments and deliveries). Old Vercel Blob addresses in the database
keep working.
Revision requests & disputes - both are lightweight flags + a note on
the Order row, not a whole workflow engine. A revision request emails
the seller; a dispute emails every admin account. Disputes show up on
/admin in a dedicated queue with Refund buyer (reverses the actual
Stripe charge) and Release to coach (same release path a buyer would
normally trigger) - an admin decides, nothing resolves itself
automatically.
Scheduled calls - see "Phase 3" at the end of this file: students
book calls from the mentor's availability on the order page
(/api/orders/[id]/calls), with emails and calendar invites.
Email notifications - new message, order paid, payment released,
revision requested, dispute opened, and review received all trigger an
email via Resend, defined in lib/email.ts. These are all best-effort -
a failed email logs an error but never blocks the underlying action.
Terms of Service / Privacy Policy - /terms and /privacy are
placeholder legal text, clearly marked as such in the pages themselves.
Get a real lawyer to draft or review these before you launch publicly -
this matters more than usual here given the escrow/payment mechanics.
Bug fix included in this batch: the seller dashboard (/dashboard)
was fetching every seller's gigs instead of just the logged-in seller's
own - fixed via a ?mine=true param on /api/gigs that scopes the query
to the current session.
Replaced (Oct 2026 hourly redesign). Kept for history: packages no longer have due dates, the 96-hour auto-release and its cron are gone, and mentors are paid monthly. See "Oct 2026 hourly redesign" at the end of this file.
This replaces the old "buyer clicks release" flow entirely.
The sequence now:
IN_ESCROW (paid, work in progress) - same as before./api/orders/[id]/mark-complete).
This moves the order to COMPLETED and records workCompletedAt. The
buyer gets an email immediately.releaseOrder() (in lib/orderRelease.ts) is the actual
code that moves money - it's shared between the authenticated route
and the cron job so there's exactly one place that logic lives.Discount codes were removed entirely in Oct 2026 (no promos); see
prisma/cleanup-2026-10-drop-discount-codes.sql.
Two cron jobs, defined in vercel.json:
/api/cron/auto-release runs hourly - finds every COMPLETED order
where workCompletedAt is more than 48 hours ago (and isn't disputed)
and releases it./api/cron/overdue-reminders runs once daily at 9am - finds every
IN_ESCROW order past its due date that hasn't been reminded about in
the last ~20 hours, and emails the seller.Both routes check for a Bearer ${CRON_SECRET} header - Vercel sends
this automatically for routes listed in vercel.json, but cron jobs
only run once your project is actually deployed to Vercel - they will
NOT fire while you're just running npm run dev locally. To test either
job locally, call it manually with the header set:
curl -H "Authorization: Bearer your-cron-secret" http://localhost:3000/api/cron/auto-release
Revisions and disputes now work during the 48-hour window too, not
just before. One important detail: if a buyer requests a revision
while the order is COMPLETED, the order resets back to IN_ESCROW and
workCompletedAt is cleared - this stops the auto-release cron from
paying the seller for work that's mid-revision. Disputes work slightly
differently: the order's status doesn't change, but disputed orders are
excluded from the auto-release query entirely until an admin manually
resolves them (refund or release).
Replaced (Oct 2026 hourly redesign). Kept for history: packages no longer have due dates, the 96-hour auto-release and its cron are gone, and mentors are paid monthly. See "Oct 2026 hourly redesign" at the end of this file.
Every revision request (/api/orders/[id]/request-revision) automatically
sets the order's due date to exactly 7 days from the moment it's
submitted, overwriting whatever it was before.
If 7 days genuinely isn't enough for a given revision, the seller can
override it with their own date via /api/orders/[id]/due-date - this
route is seller-only (a buyer moving their own deadline would defeat the
point of having one), and shows up as a small "Change due date" button
right next to the due date on the order page.
Opening a dispute now creates its own dedicated thread - separate from the regular buyer/seller conversation - where the buyer, seller, and any admin can all read and post messages. This is specifically so an admin can ask follow-up questions before deciding, rather than only seeing the one-line reason submitted when the dispute was opened.
Confirming the important part: nothing releases automatically once a
dispute is open. The auto-release cron explicitly excludes any order
where disputed: true (see /api/cron/auto-release), regardless of how
much time has passed since the seller marked it complete. The only two
ways money moves after a dispute is opened are an admin calling
/api/orders/[id]/refund or /api/orders/[id]/release - both require
an active admin session, and both are just buttons on /admin.
Pieces involved:
DisputeMessage model - a flat list of messages tied to an order, separate from Conversation/Message./api/orders/[id]/dispute-messages - GET/POST, accessible to the order's buyer, its seller, or any admin.lib/hooks/useDisputeThread.ts - same real-time pattern as useConversation, listening for dispute-message events (lib/realtime.ts)./admin, each disputed order has a "Respond / get more details" button that expands into the live thread, right above the Refund/Release buttons.order.disputed is true.You don't need every single service fully configured before your first
npm run dev. Only two things are truly required for the app to even
start:
DATABASE_URL (Neon)NEXTAUTH_SECRETEverything else fails gracefully - Stripe, Supabase (files and live messages), Daily, Resend, and Google sign-in only throw an error when you actually click the specific feature that needs them (checkout, messaging, video, invite emails, "Continue with Google" respectively), not on startup. This makes it practical to set things up incrementally rather than all at once - see the person-facing setup guide for a suggested order.
Replaced (Oct 2026 hourly redesign). Kept for history: packages no longer have due dates, the 96-hour auto-release and its cron are gone, and mentors are paid monthly. See "Oct 2026 hourly redesign" at the end of this file.
Two related changes:
The buyer's review window is now 96 hours (was 48) - every place that
referenced this (the order page, the mark-complete/release/revision
routes, the emails) has been updated consistently. AUTO_RELEASE_HOURS
in app/orders/[id]/page.tsx and WINDOW_HOURS in
app/api/cron/auto-release/route.ts are the two constants that actually
control this - change both together if you ever want a different number.
The auto-release cron now runs once daily instead of hourly. This
wasn't optional: Vercel's free Hobby tier only allows cron jobs to run
once per day - an hourly schedule literally fails to deploy on that
tier, it's not just throttled. Rather than requiring a $20/month Vercel
Pro upgrade or a third-party scheduler account just to get slightly
tighter timing, this runs once a day at 3am UTC, which adds at most
~24 hours of slack on top of the 96-hour window - a small fraction of
it, not a meaningful practical delay. If you later upgrade to Vercel
Pro, switching vercel.json's schedule for /api/cron/auto-release
back to "0 * * * *" (hourly) is the only change needed to tighten it
up - no code changes required.
Replaced (Oct 2026 hourly redesign). Kept for history: packages no longer have due dates, the 96-hour auto-release and its cron are gone, and mentors are paid monthly. See "Oct 2026 hourly redesign" at the end of this file.
Card payments don't become spendable in your Stripe balance the instant they're charged - Stripe holds them briefly before they're "available" to transfer onward. Attempting a Transfer before that point fails.
Rather than guessing a generic "wait 2 business days," this uses Stripe's own exact number for each specific payment:
checkout.session.completed webhook),
the code fetches that charge's balance_transaction.available_on
timestamp - Stripe's own calculation of when that specific payment
becomes available, based on card type, currency, and your account's
payout schedule. Stored on the order as fundsAvailableAt.releaseOrder() (in lib/orderRelease.ts) checks this before ever
attempting a Transfer. If funds aren't available yet, it throws a
specific FundsNotYetAvailableError instead of letting Stripe's API
call fail with a less clear error.COMPLETED - tomorrow's
run picks it up again automatically once funds have actually cleared.If the availability lookup itself fails for some reason (network hiccup,
etc.), fundsAvailableAt just stays null and the order is treated as
available rather than blocked forever on a missing value - a release
attempt might then occasionally hit Stripe's own error instead, which is
an acceptable fallback for a rare lookup failure.
This is a website, not a native app. The backend (app/api/) would
be fully reusable by a real iOS/Android app later, but the pages
themselves (app/coaches/page.tsx etc.) are built with web technology
and would need to be rebuilt in something like React Native to become a
true App Store/Play Store app. That's a legitimate separate project, not
a small add-on - don't start it until the website itself is proven out.
What IS done now:
app/globals.css now has explicit
small-screen adjustments (tighter padding, smaller headings) for
actual phones.public/manifest.json plus the
manifest/themeColor fields in app/layout.tsx let someone add the
site to their phone's home screen with a proper icon and app-like
toolbar color, without needing an App Store at all. One thing you
still need to do: manifest.json points at /icon-192.png and
/icon-512.png, which don't exist yet - drop two actual PNG icon
files (192x192 and 512x512 pixels) into the public/ folder with
those exact names once you have real branding art.app/layout.tsx now has
proper openGraph metadata, so a link to the site shared in a text or
on social media shows a real title/description card instead of a bare
URL. Per-page metadata (so /coaches/[id] shows that specific coach's
name in search results, for example) isn't set up yet - that's a
reasonable next addition once the site's live and you want to optimize
individual pages for search.Real bug, found during testing: a seller had no page at all for viewing
and replying to a message a buyer sent them before booking. The old
"Messages" tab on /orders always displayed conversation.seller -
correct when a buyer is looking (shows the coach they're talking to),
but wrong when a seller is looking (it showed themselves, linking to
their own profile page instead of a place to reply to the buyer).
Fixed with:
/messages/[conversationId], that works
correctly for either side - it looks at the logged-in user's role and
shows whichever person is the OTHER party in that specific conversation./orders "Messages" tab now links there instead of always
linking to /coaches/[sellerId].The buyer-side experience (messaging from a coach's public profile page, pre-booking) is unchanged - this fix is specifically about giving sellers an equivalent way to see and respond to messages.
Another real gap from testing: /messages/[id] (the new shared thread
page) only showed the conversation - a buyer who navigated there via
"My orders → Messages" had no way to actually book, unlike the coach's
public profile page which always had packages listed alongside the
thread.
Fixed: the thread page now also shows that seller's packages (for a buyer viewing it), with the same booking-unlock rule as the profile page
Stripe changed its defaults for new accounts: brand-new Stripe accounts
no longer get access to the older "v1" way of creating connected
(seller) accounts, and - as discovered during testing - the dashboard
toggle meant to re-enable v1 compatibility isn't available on every
account/sandbox. Rather than block on that, /api/stripe/connect now
calls Stripe's current v2 Core Accounts API directly via fetch()
(rather than the stripe-node SDK's older typed methods, since the
installed SDK version predates v2 support) using a recipient
configuration - exactly what a payout-only seller needs: the ability to
receive Transfers from the platform, nothing about accepting their own
separate customer payments.
Nothing else needed to change. Once a v2-created account exists, it
works as a normal Transfer destination (see lib/orderRelease.ts) and
Checkout Sessions (buyer payment) are completely unaffected - Stripe's
account-creation restriction didn't touch either of those.
Another real gap: /dashboard/payouts always showed the same "Connect
bank account" button, regardless of whether the seller had already
connected - it never actually checked. Fixed with a new
/api/stripe/status endpoint that asks Stripe directly whether the
account still has outstanding requirements (not just whether a
stripeAccountId exists in our own database, since that gets saved
BEFORE the redirect to Stripe's form - someone could abandon partway
through and still technically "have an account" on our side).
The page now shows one of three states: not started, started-but- incomplete (with a "Finish setup" button that resumes exactly where they left off), or fully connected.
Next.js 14.2.5 had a real, publicly disclosed security vulnerability
(patched in December 2025 - affects apps using the App Router, which
this project does). package.json now specifies 14.2.35, the patched
version in the same 14.x line. Run npm install again after pulling
this change to actually get the new version - just updating the file
isn't enough, npm needs to re-fetch it.
Moved from a separate "Start video call" button (which only showed
under certain order statuses) to a small 🎥 button sitting directly next
to Send in every place a message can be typed: the pre-booking thread on
a coach's profile, the standalone /messages/[id] page, and the order
detail page. It's now always available wherever there's a conversation
to have, not gated by order status.
Previously the order page only showed the message thread when the order
was IN_ESCROW or COMPLETED - now it shows whenever the order has a
conversationId at all, same as the video button above. Revisions and
disputes still require an active (non-final) order status, since those
genuinely only make sense while work is in progress.
This isn't new to this update, but worth calling out clearly: an order
only moves from PENDING_PAYMENT to IN_ESCROW when our server
receives a webhook from Stripe confirming payment - and Stripe's servers
can't reach localhost directly. Without forwarding, a test order can
appear to "pay successfully" (Stripe processes the card) while your own
database never gets told about it, leaving the order stuck.
To fix this for local testing:
brew install stripe/stripe-cli/stripe (or see https://docs.stripe.com/stripe-cli for other install methods)stripe login (opens a browser to connect it to your account)stripe listen --forward-to localhost:3000/api/webhooks/stripe
whsec_... - copy that into .env as STRIPE_WEBHOOK_SECRET, replacing the placeholdernpm run dev so it picks up the new env valuestripe listen command running in its own tab any time you're testing a real checkout locallyOnce deployed to Vercel, this isn't needed anymore - Stripe can reach a real public URL directly, so this forwarding step is purely a local development workaround.
Only the coach's public profile (pre-booking thread) had a paperclip
button - /messages/[id] and the order detail page could display
existing attachments but had no way to send one. Both now have the
same 📎 button as the profile page, using the same useConversation
hook's file-upload support that was already built - this was purely a
missing UI element, not a missing feature.
All three messaging screens (coach profile thread, /messages/[id],
order detail page) now accept a file dropped directly onto the message
box, not just the paperclip button. The box's border highlights green
and dashed while a file is being dragged over it, then reverts once
dropped or cancelled. Both methods (drag or click 📎) set the same
pendingFile state and go through the same upload path when Send is
clicked - no duplicated upload logic.
Attachment links point at our own download routes
(/api/files/message/[id], /api/files/delivery/[id]/[index]), which
check the person may see the file and then send them to a 5-minute
Supabase Storage link created with the original file name as a download,
so images and PDFs save to Downloads instead of opening in the tab. Older
attachments still stored on Vercel Blob are sent to their old address with
?download=1, which does the same.
Real deployment bug: the build script was just next build, relying on
Prisma's client code being auto-generated via an install script - the
same kind of script that needed manual approval locally
(npm approve-scripts). On Vercel, that generation step can be skipped
silently, leaving every route that imports Prisma unable to actually
query the database the moment Next.js tries to analyze it during build -
which is exactly what "Failed to collect page data" for various API
routes was.
Fixed by making the build script explicit: "build": "prisma generate && next build".
This is the standard, documented fix for deploying Prisma-based apps to
Vercel - never rely on the generation step happening implicitly.
Real production-build error: any page using useSearchParams() (this
page reads the invite code and email from the URL) needs to be wrapped
in a <Suspense> boundary for Next.js's production build to prerender
it correctly - dev mode doesn't enforce this, so it never showed up
locally. Fixed by splitting the page into a thin wrapper (default
export, wraps everything in <Suspense>) and an inner component with
all the actual logic, unchanged.
Added after a security review before real student data goes through this site. Previously, nothing stopped someone from guessing a password thousands of times in a row.
How it works: two new fields on User - failedLoginAttempts and
lockedUntil. Every wrong password increments the counter; after 5
wrong attempts in a row, the account locks for 15 minutes (during which
even a correct password is refused). A successful login resets the
counter to zero.
Deliberately generic messaging: a locked-out attempt returns the same "wrong email or password" response as an actually-wrong password, rather than a distinct "you're locked out" message - this avoids confirming to an attacker that a given email address has an account at all versus just being rate-limited.
This uses your existing database rather than adding a new service (like Redis) - simpler to operate at this stage, and sufficient for your current scale. If traffic grows enough that this becomes a bottleneck, a dedicated rate-limiting service (e.g., Upstash) would be the next step up - not needed yet.
New look (Iris colors, Fraunces + Instrument Sans, Leland-style layout) on every page, plus:
/account. Anything missing is hidden from search, and the mentor sees a warning.lib/callRules.ts, runs with the daily 09:00 cron): reminders, then "on hold" at the due date, then forfeit after 48h if the mentor had call hours set (Phase 3). All timings are constants in lib/calls.ts./account#calendar gives every user a private .ics feed (Google / Outlook / Apple)./dashboard (availability switch, needs-attention list, active/completed orders, messages), /dashboard/packages, /dashboard/payouts (debug panel removed), /account (profile, search answers, call hours, busy dates, calendar, pause / remove)./messages inbox with an order side panel.Database: run npx prisma db push (or paste prisma/redesign-2026-09.sql into the Neon SQL editor) before deploying.
Marketing content (schools strip, testimonials, photos) started in lib/content.ts; it's now edited in Admin -> Website, and lib/content.ts only holds the built-in defaults. Empty items are hidden.
Cal.com is gone. Everything happens on the site:
/account#call-hours): weekly hours per weekday, time zone, break between calls, minimum notice, single days off. Rules and slot maths: lib/schedule.ts.lib/icalBusy.ts) and hidden from booking./account#busy-dates): date ranges with a private note, blocking due dates ("No deadlines") or due dates and calls. Mentors are warned (on screen and by email) when new busy dates cover deadlines they already have; those orders keep their dates./api/orders/[id]/slots, /api/orders/[id]/calls). Booking creates the call and a private Daily room, and emails both people with a calendar invite (.ics). Reschedule and cancel work on the site until 24 hours before (CANCEL_CUTOFF_HOURS), with updated invites. Mentors can still add a time they agreed by hand./calls/<id> is the pre-join screen (recording notice), then the private room with a per-person token. Attendance (join/leave times) and recordings come in through /api/webhooks/daily; attendance shows on the order page and in Admin -> Calls & recordings, recordings are admin-only ("Watch recording") and deleted 60 days after the order closes (daily, with the auto-release cron)./calendar): upcoming and past calls, plus due dates and busy dates for mentors. The private subscribe feed on /account#calendar stays./api/cron/call-reminders runs every 15 minutes from vercel.json (needs Vercel Pro, which the project is on).lib/callOutcomes.ts, lib/callOutcomeRunner.ts). Admins can change any outcome in Admin -> Calls & recordings. Details: items 4 and 5 in . SQL: .Setup: run prisma/phase3-2026-10.sql in the Neon SQL editor (after a backup branch) before deploying. Env vars: DAILY_API_KEY (video), DAILY_RECORDING_ENABLED="true" (recording, paid Daily plan), DAILY_WEBHOOK_SECRET (from Admin -> Calls & recordings -> Connect Daily webhook).
Spec: docs/redesign-2026-10.md. Database: prisma/hourly-redesign-2026-10.sql (only adds things; run it on Supabase, live and preview, before the code goes live).
lib/pricing.ts, lib/offers.ts). Buying unlocks after the mentor replies or a free 15-minute intro call is held (lib/unlock.ts, lib/introCalls.ts).kind (HOURLY / BUNDLE / SPECIALIZED / PAY_PER_CALL), minutes bought (plus paid HourTopUps), the rate paid and no due date. Statuses for these: IN_ESCROW = active (including waiting on the student), COMPLETED = approved and waiting for the monthly payout, RELEASED = paid to the mentor, REFUNDED = refunded in full as credit.HourEntry, lib/hourLog.ts): held calls are logged automatically at 30/60 minutes; mentors log written work. lib/hours.ts has the maths.lib/finishing.ts, lib/orderFinish.ts): Mark work complete -> the student has 48 hours to approve, ask for one last call or revision, or dispute; otherwise auto-approved.lib/payoutRules.ts for the rules, lib/payouts.ts for the work): per mentor, unpaid non-voided hour entries plus unused minutes of approved packages, at the package rate; disputed packages held; one MentorPayout per mentor per month (period "YYYY-MM"), claimed in a transaction, then one stripe.transfers.create (idempotency key payout-<mentor>-<period>, transfer_group = payout id). Failures are marked FAILED, reported as "[NEEDS MANUAL CHECK]", badge Admin -> Orders & payouts and can be retried there (Stripe is checked for an existing transfer first). Owner-only "Run payouts now" runs the same function for the latest period.CreditEntry ledger, lib/credit.ts, lib/creditAdmin.ts): used first at checkout; disputes are settled with "Refund as credit" (full or partial; a partial refund is taken off the mentor's final payout for that package); admins with Orders can adjust a student's credit with a reason.vercel.json): /api/cron/call-reminders every 15 minutes (reminders, call outcomes, 48-hour auto-approval); /api/cron/monthly-payouts at 14:00 UTC on the 24th; /api/cron/recordings-cleanup daily at 03:00 UTC (recordings 60 days after a package closes); /api/cron/safety daily at 08:00 UTC. The old and (due dates, on hold, forfeits, "book your call") are gone.docs/open-questions.mdprisma/call-outcomes-2026-10.sql/api/cron/auto-release/api/cron/overdue-reminders