- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
The applicant-facing portal runs at school.databayt.org. Applying requires a login (the wizard is auth-gated, and the actions enforce it server-side); status tracking stays account-less via OTP. After choosing a campaign, the applicant enters a 5-step wizard with auto-save (every 30 s + localStorage, with a server-session fallback when localStorage is lost), and submits on the Fees step. Applying is always free — the only payment in the whole journey is the registration fee, after an offer is accepted.
Steps
1. Visit /admissions
2. Log in (auth-gated) → choose campaign / start application
3. Overview — drafts + new application
4. Wizard — 5 steps (3 phases)
5. Submit (on the Fees step — free, no payment)
6. Success dialog (inline) → application appears under "Your applications"
7. Offer (post-decision) → registration fee to secure the seat
Wizard
Single shared FormFooter with three progress bars (one per phase). 30 s auto-save + localStorage backup.
| # | Step | Required fields | Route |
|---|---|---|---|
| 1 | Attachments | Optional by default (photo, degree, transcript, ID, resume, other) — triggers AI extraction. When the school sets requireDocuments, this step blocks Next with a visible hint until ≥ 1 document is uploaded | /{lang}/application/{id}/attachments |
| 2 | Personal | Student name, dateOfBirth, gender, nationality + guardian (Father/Mother) in tabs | /{lang}/application/{id}/personal |
| 3 | Location | address, city, state, country (Mapbox if NEXT_PUBLIC_MAPBOX_TOKEN) | /{lang}/application/{id}/location |
| 4 | Academic | applyingForClass (14 grade levels: KG1, KG2, Grade 1–12) | /{lang}/application/{id}/academic |
| 5 | Fees | Informational fee preview by grade (applying is free — no payment UI) — submits the application | /{lang}/application/{id}/fees |
The Personal step uses single "Full Name" or split fields based on the school's nameFormat, and folds the former Guardian step into Student / Father / Mother tabs. The former standalone Contact step was removed — email is back-filled at submit from the session / logged-in user. Fees is the final step: it previews the tuition for the chosen grade, then its button submits the application and shows the success dialog inline. Each step's Next button is gated by the same rules the real validation enforces (phone length, ≥ 2-character parent name), and any save/validation failure surfaces as an inline alert — never a silent no-op.
Phase grouping
| Phase | Label | Steps |
|---|---|---|
| 1 | Basic Information | Attachments, Personal |
| 2 | Address | Location |
| 3 | Academic | Academic, Fees |
Submit flow
// Server-side
1. Resolve school from subdomain
2. Validate via applicationFormDataSchema (Zod)
3. Verify session belongs to this school
4. Check campaign OPEN and not expired
5. Check duplicate email + campaign (unless allowMultipleApplications)
6. Generate application number `APP-{year}-{random6}`
7. Generate access token (32 chars, 6-month expiry)
8. Create Application (status: SUBMITTED)
9. Mark session as converted
10. Notify admins + send applicant confirmation email (Resend)The caller must be signed in (NOT_AUTHENTICATED otherwise), documents are
required at this point only as a backstop (DOCUMENTS_REQUIRED — the
attachments step already gates it), and duplicates are blocked both in code
and by the @@unique([schoolId, campaignId, userId]) DB constraint
(APPLICATION_DUPLICATE). There is no payment step — the retired
application-fee leg was removed entirely.
Success dialog
Submission opens the success dialog inline on the fees step (the standalone success/payment pages were deleted):
- Application Tracking Code (application number + access token for the OTP tracker)
- Next steps timeline (email → review → decision)
After submitting, the application stays visible under "Your applications"
on the authenticated /{lang}/application dashboard — status badge,
application number, submitted date, and a View offer link once SELECTED.
Offer & registration fee (post-decision)
Once an admission decision is made, /{lang}/application/{id}/offer lets the applicant accept or decline and pay the registration fee to secure the seat — the only payment in the flow. It is reached via an emailed offer-token link or the dashboard's View-offer link. Methods (gated by what the school's currency/region supports): card checkout (webhook-confirmed), cash and bank transfer (admin-confirmed). Amounts render via locale-aware formatCurrency. Offers lapse: a daily job flips SELECTED applications past offerExpiryDate to EXPIRED; an admin can re-offer (EXPIRED → SELECTED).
Application status banner
Server-rendered banner on the school site for logged-in applicants. Dismissible per application via localStorage.
| Status | Message | CTA |
|---|---|---|
| SUBMITTED | "Application #N — Submitted" | — |
| UNDER_REVIEW | "Application #N — Under Review" | — |
| SHORTLISTED | "Application #N — Shortlisted" | — |
| SELECTED | "Congratulations! You've been accepted" | View Offer |
| SELECTED + paid | "Accepted — Payment received" | Go to Dashboard |
| EXPIRED | "Application #N — Offer expired" | — |
| ADMITTED | "Welcome! Enrollment confirmed" | Go to Dashboard |
| WAITLISTED | "Application #N — Waitlisted" | — |
| REJECTED | "Application #N — Not Selected" | — |
Banner includes a 4-dot timeline: Submitted → Review → Decision → Enrolled.
Sessions
Storage key hogwarts_apply_session_{campaignId}_{userId} (scoped per user to prevent cross-account draft leakage). Session token is server-generated nanoid(32), 7-day expiry. Auto-save every 30 s when dirty. Browser warns on unsaved navigation.
Three resume paths: localStorage (auto), email link /{lang}/application/continue?token=xxx, authenticated user drafts on the landing page. The first save triggers a "resume application" email.
Status tracking (no account)
/{lang}/application/status
→ enter application number + email
→ 6-digit OTP via email (10-min expiry)
→ max 3 OTPs / hour, 5 verify attempts
→ view timeline
For the full status flow, see Admission → Status flow. Branches: WAITLISTED, REJECTED, WITHDRAWN.
Tour booking
Self-service at /{lang}/tour. Calendar → time slots → booking form (visitor name, email, phone, student name, grade interest, attendees, notes) → confirmation with calendar invite.
| Slot type | Purpose |
|---|---|
| TOUR | Campus tour |
| INTERVIEW | Admission interview |
| ORIENTATION | New student orientation |
| OPEN_HOUSE | Open house event |
View / reschedule / cancel at /{lang}/tour/{bookingId}.
Inquiry form
Lead capture at /{lang}/inquiry for families not ready to apply. Required: parent name, email. Optional: phone, student name, DOB, target grade, source (website / social / referral / open house / ad), message, newsletter opt-in.
Status flow: NEW → CONTACTED → QUALIFIED → CONVERTED → CLOSED.
Server actions
The wizard itself is auth-gated (login required to apply). Status tracking, tour booking, and inquiry capture remain public (no account needed).
| Action | Parameters |
|---|---|
getActiveCampaigns() | subdomain |
saveApplicationSession() | subdomain, formData, step, email, campaignId, sessionToken? |
resumeApplicationSession() | sessionToken |
submitApplication() | subdomain, sessionToken, formData |
requestStatusOTP() | subdomain, appNumber, email |
verifyStatusOTP() | subdomain, appNumber, otp |
getApplicationStatus() | accessToken |
getAvailableSlots() | subdomain, slotType, date |
createTourBooking() | subdomain, bookingData |
submitInquiry() | subdomain, inquiryData |
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Application not saving | Network error, expired session (7 d), subdomain not resolving |
| Submit fails validation | Missing required field across steps; check error message field names |
| OTP not received | Spam folder, wrong email, rate limit (3 / h) |
| Offer page shows no methods | School currency/region unsupported by every gateway — cash/bank should always remain |
Key files
| Purpose | Path |
|---|---|
| Routes | src/app/[lang]/s/[subdomain]/application/ |
| Step config | src/components/school-marketing/application/config.client.ts |
| Context | src/components/school-marketing/application/application-context.tsx |
| Submit | src/components/school-marketing/application/submit-action.ts + admission/actions/application.ts |
| Validation | src/components/school-marketing/admission/validation.ts |
| Offer + fee | src/components/school-marketing/application/offer/{actions.ts, gateways.ts, content.tsx} (registration fee — the only payment) |
| Stripe webhook | src/app/api/webhooks/stripe/route.ts |
| Schema | prisma/models/admission.prisma |
| Tests | school-marketing/admission/__tests__/{status-banner.test.tsx, status/__tests__/status-display.test.tsx, actions/__tests__/status-tracking.test.ts} + app/api/webhooks/stripe/__tests__/route.test.ts |
See also
- Admission — admin-side flow
- Multi-tenancy — subdomain resolution
- Notifications —
fee_paiddispatch