Easy-to-manage touch screens
무료 오픈소스 터치스크린으로 USB 설정 가이드, 드래그‑드롭 편집기, 로컬 제어를 제공하며 Home Assistant용으로 제작되었습니다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기A booking engine for private charter flights
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Private aviation charter marketplace. Travelers submit multi-leg flight requests, certified charter operators compete with quotes, and bookings happen directly — no broker markup.
There are three user roles — customer, operator, and admin — each with their own authenticated views and workflows.README 전문 보기 ↗Private aviation charter marketplace. Travelers submit multi-leg flight requests, certified charter operators compete with quotes, and bookings happen directly — no broker markup.
Stack: Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Postgres (Neon via Vercel) · Stripe (Checkout + Connect) · Resend (email) · Vercel Cron
Traveler submits request → Matching engine scores operators → RFQs dispatched via email/SMS
↓
Operators quote from their dashboard
↓
Traveler compares and accepts a quote
↓
Booking confirmed
There are three user roles — customer, operator, and admin — each with their own authenticated views and workflows.
src/lib/access.ts and are covered by src/app/api/authz.test.ts.src/lib/rate-limit.ts, stored in the rate_limits table so they hold across serverless instances): sign-in/sign-up 10 per 15 min per IP; new booking requests 5 per hour per user; trip-sheet emails and staff test alerts 10 per hour; manual discovery runs 3 per hour. Exceeding one returns 429 with Retry-After.Two booking forms. The home page and /book use the ticket form: each leg is a paper ticket with From, To, date, departure time and guests printed into it; Add a leg issues another ticket starting where the last one lands (src/components/booking/ticket-booking-form.tsx). The step-by-step classic form is at /book/classic and is described below. Each request records which form produced it (booking_requests.form_variant); the admin dashboard compares requests and bookings per form. Once every leg is complete, a signed-in traveler's request goes out automatically after 5 seconds (any change restarts the timer; Wait pauses, Send now skips it; a restored draft waits). A visitor sees the account form as soon as the route is complete. There is no passenger cap; operators split large groups across aircraft.
Build an itinerary at /book/classic. "Start Booking" on the home page goes straight here; no account is needed. Add origin, destination, date and time for each leg; multi-leg trips (A → B → C → A) are supported. Each leg shows its great-circle distance (nm, mi, km) once both airports are known, and the route header totals the trip. Set leg collapses a finished leg to a summary with Edit and Cancel leg (undoable); Add a leg sits at the bottom right of the last leg. Airport coordinates come from OurAirports (src/lib/airports.ts); the math is in src/lib/distance.ts. Special requests are chips: preset tags (pets, surfboards, ground transportation, medical devices and more) and up to six of the traveler's own, added with Add your own and edited or removed in place (src/lib/bookings/request-tags.ts). Both are kept in the draft and folded into the request's notes on submit, so operators see them wherever special requests appear. The itinerary is saved in the bf_booking_draft cookie on every change (30 days, SameSite=Lax), so reloading or returning later restores it.
Sign up or sign in at submit. A visitor without a session gets an inline account step on /book. After registering or signing in, the server turns the saved itinerary into a booking request, clears the cookie, and opens the new request. Signing in through /login or /register with a saved itinerary does the same. Signed-in travelers submit directly.
Wherever a trip that is being booked appears (the booking form's live summary, the account step, request lists and pages for travelers, operators and admins, the Stripe Checkout line item, and the booking emails), it shows every detail: route, each leg's date and time ("Any time" if none was given), passengers, and special requests ("None" if empty). The shared component and produce this.
Register at /register?role=operator. Account starts in pending status.
Configure profile at /operator/settings once approved by an admin:
Receive inbound RFQs at /operator/inbound. These are charter requests automatically matched to the operator's profile by the matching engine. Each card shows the route, pax count, dates, match score, and time since receipt.
Submit a quote inline — price, aircraft (from fleet inventory), valid-until date, and an optional message. Work out the price opens a worksheet (src/lib/quotes/worksheet.ts): leg hours estimated from distance and the aircraft's cruise speed, hourly rate, positioning, daily minimum, fuel surcharge, crew overnights, landing/handling, catering, other costs, margin, federal excise tax (7.5% default) and per-passenger segment fees; Use as the price fills the price. The worksheet is saved in quote_worksheets, readable only by the quoting operator (GET /api/quotes/[id]/worksheet), and can be edited when revising. Travelers and admins never see it. One click creates the quote and marks the outreach log as responded.
Browse all demand at /operator/requests to see every open request on the platform, not just auto-matched ones.
Share a booking with the team. Once a booking is paid, the winning operator's request page shows Share with your team:
/trip/<token>. No login is needed to view it. It shows legs with dates and times, aircraft, passengers, special requests, and the lead passenger's name, phone and email. It prints cleanly and is not indexed by search engines./trip/<token>/calendar): one event per leg, at the requested local time, or all-day when no time was given./admin/dashboard — system-wide stats (users, operators, requests, quotes, bookings) plus the operator discovery pipeline summary./admin/operators — approve or suspend, Add operator (company, contact, sign-in email, certificate, status), and edit any operator's company profile: name, status, certificate, request contact, safety rating, markets, aircraft categories, range, platform fee override, notes./admin/users — search and filter by role, Add user (any role; an operator needs a company name), and edit name, phone and role. An admin cannot change their own role, and the last admin cannot be demoted. Making someone an operator creates their company profile; a former operator keeps theirs. Role changes apply on the user's next request (the role is read from the database each time). New accounts get a temporary password shown to the admin once; in production the first sign-in with it moves the account to Neon Auth. Routes: POST /api/admin/users, PATCH /api/admin/users/[id], POST /api/admin/operators, PATCH /api/admin/operators/[id]./admin/requests./admin/discoveries — manage FAA-discovered operators (details below)./admin/payments — gross bookings, net platform fees, refunds, disputes; issue full or partial refunds.When a customer submits a booking request, the matching engine runs synchronously before the API response returns.
Each approved operator is scored on a 100-point scale:
| Factor | Weight | Logic |
|---|---|---|
| Market coverage | 40% | Operator's configured markets include the classified market for this route |
| Fleet type match | 20% | Operator's fleet categories can serve the passenger count and route distance |
| Safety rating | 25% | ARGUS Platinum / Wyvern Wingman = 25, Gold = 18, Part 135 = 10 |
| Past performance | 15% | 3 points per accepted quote, capped at 15 |
Penalties are applied for route/capability mismatches:
hi_capable: −30transoceanic: −20The top 10 operators scoring above 20 are dispatched an RFQ.
Routes are classified into one of 10 markets based on ICAO airport codes:
| Market | Examples |
|---|---|
hi_inter | PHNL ↔ PHOG, PHKO, PHLI |
mainland_hi | KLAX → PHNL, KJFK → PHNL |
west | KLAX, KSFO, KSEA, KSAN, KPDX |
east | KJFK, KTEB, KBOS, KPBI, KIAD |
se | KATL, KMIA, KFLL, KTPA, KMCO |
central | KORD, KDEN, KMSP, KDTW |
sw | KDFW, KHOU, KLAS, KPHX |
carib | TNCM, TJSJ, MYNN |
transatl | EGLL, LFPB, EDDF |
pacific | Pacific island codes |
Matched operators receive a branded HTML email via Resend containing the full itinerary, passenger count, notes, and a link to submit their quote. Operators whose contact method is text or both also get a text through Twilio. Without a RESEND_API_KEY, emails are logged to stdout. Without Twilio credentials, or in test mode, texts are logged instead.
When INBOUND_EMAIL_SECRET is set, each RFQ email gets a Reply-To of quotes+<token>@<domain> (outreach_log.reply_token; the domain is EMAIL_REPLY_DOMAIN, else the domain of EMAIL_FROM) and tells the operator they can reply with a price. Point the email provider's inbound webhook at POST /api/webhooks/inbound-email with Authorization: Bearer $INBOUND_EMAIL_SECRET; the body is {from, to, subject, text, html} or {data: {...}}. The first dollar amount in the reply's own text (above any quoted original) is the all-in price; an N-number matching the operator's fleet sets the aircraft and valid 2026-11-01 sets the validity; the rest of the reply is the message shown to the traveler. The token is the credential, and the sender must be the operator's contact or sign-in address. The quote is created through the same path as the dashboard (quotes.source = 'email'), the traveler is notified, and the operator gets a confirmation or a "quote not recorded" email with the reason. src/lib/quotes/by-email.ts.
A weekly automated pipeline finds new charter operators and invites them to join the platform.
GET /api/cron/weekly-discovery every Monday at 8:00 AM HST (18:00 UTC), sending Authorization: Bearer $CRON_SECRET. The route refuses every call when CRON_SECRET is unset. Each FAA request has an 8 s timeout and the scrape stops after 35 s so storing and emailing fit the 60 s function limit; states that were skipped or failed are listed in the run's errors.av-info.faa.gov for Part 135 on-demand air taxi certificate holders, processing 10 states per run and rotating through all US states across ~5 weeks.discovered_operators table or registered on the platform.no_email.new. The branded email explains the marketplace, links to registration, and includes the operator's FAA certificate number.| Status | Meaning |
|---|---|
new | Has email, ready to send invite on next run |
no_email | Found in FAA registry but no email — admin needs to add one |
emailed | Invite sent via Resend |
registered | Operator created an account on the platform |
opted_out | Marked by admin as not interested |
bounced | Email delivery failed |
At /admin/discoveries, admins can:
no_email operator — status auto-upgrades to new, and the next cron run sends the inviteStripe Checkout collects the customer's payment as a destination charge to the operator's Stripe Connect Express account. The operator is the settlement merchant (on_behalf_of); the platform keeps its commission as the application_fee_amount.
Customer clicks "Accept & Pay" → POST /api/payments/checkout
→ payments row (pending) + Stripe Checkout Session (expires in 1 h)
→ customer pays on Stripe → redirected to /requests/[id]?checkout=success
Stripe → POST /api/webhooks/stripe (signature verified)
→ checkout.session.completed (paid) → payment succeeded, quote accepted,
other quotes rejected, request booked, receipt + operator emails sent
PATCH /api/quotes/[id] declines (traveler), or withdraws / revises (the quoting operator, while pending).processing until checkout.session.async_payment_succeeded or ..._failed arrives. The request is not booked until funds clear.valid_until in the past) cannot be paid. The daily lifecycle job marks them expired.payment_events, and every handler can safely run more than once. If processing fails, the endpoint returns 500 so Stripe retries./requests/[id]/receipt (owner or admin) is an invoice-style, printable receipt: number BF-<year>-<payment id padded to 6>, billed-to, operator legal name with FAA certificate and safety rating, legs, aircraft and tail, each charge (booking and any paid itinerary changes) with date and payment method when known, refunds, and the net. It is linked from the trip page and the receipt email. Quote cards show the operator's certificate number and safety rating under the company name.
A traveler cancels from the trip page (Cancel trip → confirmation panel showing the refund). The refund follows the operator's cancellation policy (operators.cancellation_policy, set at /operator/settings), measured from the first leg's departure (its local date and time, or 23:59 when no time was given):
| Window | Default | Refund |
|---|---|---|
≥ full_refund_hours before departure | 72 h | 100% |
≥ partial_refund_hours | 24 h | partial_refund_bps (default 5000 = 50%) |
| Closer than that | none |
GET /api/booking-requests/[id]/cancel previews the refund; POST performs it (owner or admin): the refund goes through the normal refund path (transfer reversal and fee return), the request becomes cancelled, pending quotes are rejected, both sides get a cancellation email and the operator's staff are alerted. Unpaid requests are still withdrawn with PATCH /api/booking-requests/[id] {status:'cancelled'}.
On a booked trip the traveler can Request a change (new legs, guest count, special requests) from the trip page. One change can be pending at a time (change_requests table). The winning operator sees it on their request page and in the Change requests section of the company board, and either:
payments.change_request_id); the change is applied when it is paid, through the normal fulfillment path (webhook or the stub "simulate" button);Routes: GET/POST /api/booking-requests/[id]/changes, PATCH /api/change-requests/[id] with {action: 'accept' | 'decline' | 'withdraw', newPriceCents?, note?}. Staff alerts of kind change go to staff subscribed to Change requested.
Operators list repositioning flights at /operator/empty-legs (empty_legs table): aircraft from the fleet, route, date, optional time, date flexibility (±0–7 days), seats, an all-in price for the whole aircraft and a note. Listings are public at /empty-legs (filter ?from=HNL&to=OGG, IATA or ICAO) and show the operator's legal name, certificate and safety rating.
quoted with one leg and a pending quote at the listed price (quotes.is_empty_leg = 1, quotes.empty_leg_id, valid until the departure date) and opens the trip page, where the traveler pays through the normal checkout. A visitor's choice is kept in the bf_empty_leg cookie and booked right after sign-in or sign-up. Several travelers may hold a quote on the same listing; the first payment to clear marks the listing booked, withdraws the other quotes and emails those travelers. Checkout and fulfillment refuse a listing that is no longer listed, so a late payment is refunded automatically. A cancelled booking puts the listing back on the market if its date has not passed. The empty-leg commission from the business model applies.POST /api/operator/empty-legs/[id]/quote).Routes: GET /api/empty-legs (public), POST /api/empty-legs/[id]/book, GET/POST /api/operator/empty-legs, PATCH /api/operator/empty-legs/[id], GET /api/booking-requests/[id]/empty-legs. Service: src/lib/empty-legs/service.ts.
Travelers and operators talk in the app instead of by phone or email. One thread exists per (request, operator) once that operator has quoted (messages table, ≤ 2000 characters per message). On the trip page each quote has Message operator (expanded for the accepted quote); the operator's request page has the conversation with the traveler. Threads poll every 15 seconds. Phone numbers and email addresses are never part of the thread payload.
GET/POST /api/booking-requests/[id]/messages?operatorId= (operators omit operatorId; ?after=<id> returns only newer messages). Admins can read but not post. A new message emails the other side with a link to the thread, at most once per thread and recipient every 10 minutes (message_notifications), and raises a message staff alert for the operator on every traveler message.
The business model is configured by an admin at /admin/settings (stored as JSON under platform_settings.key = 'business_model', see src/lib/business-model.ts): default operator commission, subscriber-tier commission, empty-leg commission, launch-partner promotion (rate, booking cap, end date), processing pass-through and quote ranking are applied at checkout; the remaining fields (traveler fee, subscription price, ancillary margin, payout hold, referral fee, affiliation disclosure) are recorded for features that do not exist yet. PLATFORM_FEE_BPS (default 500 = 5%) is only the fallback before an admin saves a commission. Commission precedence: operators.platform_fee_bps override → launch promo (operators.launch_partner = 1) → empty leg (quotes.is_empty_leg = 1) → subscriber plan (operators.plan = 'subscriber') → default. The fee is rounded to the nearest cent. Stripe's processing fees come out of the platform's share unless pass-through is on.
Admins refund from /admin/payments, in full or in part. Refunds use reverse_transfer and refund_application_fee, so the operator's payout and the platform fee are returned in proportion to the amount refunded. A full refund cancels the booking. Refunds made in the Stripe dashboard are synced through charge.refunded. charge.dispute.* events record a dispute's status on the payment.
| Status | Meaning |
|---|---|
pending | Checkout session open |
processing | Bank payment submitted, not yet cleared |
succeeded | Paid; request booked |
partially_refunded / refunded | Refunded; a full refund cancels the booking |
failed | Checkout creation failed or the bank payment failed |
canceled | Session expired or replaced by a newer checkout |
| Mode | When | Behavior |
|---|---|---|
stub (test mode) | APP_TEST_MODE=true, in any environment; a Stripe key is ignored | Payments are simulated (see Test mode) |
stripe | STRIPE_SECRET_KEY set | Real Checkout and Connect |
stub | No key, NODE_ENV ≠ production | Payments are simulated: the request page shows a "Simulate successful payment" button, and payout setup completes instantly |
disabled | No key in production | Checkout returns 503, so no booking can be made without payment |
Set APP_TEST_MODE=true (for example on a Vercel deployment) to try the full flow with dummy transactions:
To go live: remove APP_TEST_MODE, set the Stripe variables, and delete the demo accounts (or start from a fresh database).
STRIPE_SECRET_KEY on the Vercel project (Production), or connect the Stripe integration to the project so it adds it. Redeploy.APP_URL, and webhooks. Create webhooks registers both endpoints at <APP_URL>/api/webhooks/stripe (payment events on the platform account; account.updated on connected accounts) and stores their signing secrets in platform_settings, so nothing is copied by hand (src/lib/payments/stripe-setup.ts). Recreating replaces endpoints at the same URL.STRIPE_WEBHOOK_SECRET / STRIPE_CONNECT_WEBHOOK_SECRET still work and take precedence.stripe listen --forward-to localhost:3000/api/webhooks/stripe with its secret in STRIPE_WEBHOOK_SECRET.PATCH /api/quotes/[id] {status:'withdrawn'}) from the request page; the traveler is emailed and the operator may quote again (the partial unique index only counts pending and accepted quotes).PATCH /api/quotes/[id] {priceCents?, message?, validUntil?, aircraftId?}). The old price is kept in quotes.previous_price_cents and revised_at is set; the traveler receives a "Quote revised" email and the quote card shows the previous price.GET /api/cron/daily-lifecycle, 10:00 UTC via vercel.json, bearer CRON_SECRET, or POST as an admin): expireQuotes sets pending quotes past valid_until to expired (one email per request to the traveler), completeTrips sets booked requests whose last leg is before today to completed (traveler emailed with a receipt link), and expireEmptyLegs sets listed empty legs whose date has passed to expired. src/lib/lifecycle.ts. The quotes.status CHECK constraint gains expired through a guarded DO block in SCHEMA_BLOCKS that runs once per database.All email is sent through Resend's API via src/lib/email/resend.ts. Templates (src/lib/email/templates.ts):
| Template | Trigger | Content |
|---|---|---|
| Operator Invite | Discovery pipeline or manual trigger | Introduces Bespoke Flights, explains the marketplace model, links to /register?role=operator |
| RFQ Notification | Customer submits a booking request | Full itinerary, passenger count, match score, link to /operator/inbound |
| New quote / Quote revised | Operator submits or revises a quote | Sent to the traveler: operator, aircraft, all-in price, validity, link to compare |
| Payment Receipt | Payment succeeds | Sent to the customer: route, dates, operator, aircraft, total paid, receipt link |
| Booking Confirmed | Payment succeeds | Sent to the operator: itinerary, quote amount, platform fee, payout |
| Booking Cancelled | Traveler cancels a paid booking | Both sides: itinerary, amount paid, refund, policy |
| Change request | Proposed, accepted (with or without payment due), applied, declined | Proposed vs current itinerary, new price, operator note |
| New message | A message arrives in a thread (≤ 1 per thread and side per 10 min) | Sender and a link to the thread; the body stays in the app |
| Status update | Request withdrawn, quote declined or withdrawn, quotes expired, trip completed | One-paragraph notice with a link |
| Trip Sheet | Operator emails a booking to colleagues | Itinerary and the private trip sheet link |
| Staff Alert | Booking, cancellation, new request, change request or message, per staff preferences | Itinerary, passengers, special requests, aircraft, lead passenger and a link |
Texts go through Twilio (src/lib/notify/sms.ts). Phone numbers are normalized to E.164: 10-digit numbers are treated as US/Canada.
A sendBatch helper handles rate limiting (1 email/second for Resend's free tier). Without RESEND_API_KEY set, all emails are logged to the console in stub mode.
src/
├── app/
│ ├── (admin)/admin/ # Admin pages: dashboard, users, operators, requests, discoveries
│ ├── (auth)/ # Login, register
│ ├── (customer)/ # Book, requests, account, dashboard
│ ├── (operator)/operator/ # Dashboard, inbound, requests, quotes, fleet, settings
│ └── api/
│ ├── admin/ # Admin endpoints: operators, users, discoveries
│ ├── auth/ # Login, logout, register
│ ├── booking-requests/ # CRUD + auto-outreach on POST
│ ├── cron/ # weekly-discovery (Vercel Cron)
│ ├── fleet/ # Operator aircraft management
│ ├── operator/ # Inbound RFQs, settings
│ ├── outreach/ # Outreach log queries
│ ├── payments/ # Checkout creation, stub-mode simulation
│ ├── quotes/ # Quote decline (acceptance is via payment)
│ └── webhooks/stripe/ # Stripe webhook receiver
├── components/
│ ├── admin/ # Operator approval actions
│ ├── booking/ # Quote accept/decline buttons
│ ├── layout/ # Header with role-based nav
│ ├── operator/ # Outreach status display
│ └── ui/ # Badge, Button, Card, Input, Select, Table, AirportInput
└── lib/
├── auth.ts # Neon Auth sessions (production); bcrypt + JWT cookie (development)
├── access.ts # Who may read or change a booking request
├── rate-limit.ts # Database-backed fixed-window limits
├── airports.ts # Airport code data
├── types.ts # All TypeScript interfaces + market/fleet constants
├── validations.ts # Zod schemas for all API inputs
├── db/
│ ├── index.ts # Postgres access: pg pool (Neon) or embedded PGlite
│ ├── queries.ts # Shared query helpers (batched legs, quote counts)
│ ├── schema.ts # Full DDL: users, operators, aircraft, booking_requests,
│ │ # booking_legs, quotes, outreach_log, discovered_operators
│ └── seed.ts # Test data: 4 operators, 2 customers, 1 admin,
│ # 2 booking requests, sample quotes, 8 discovered operators
├── discovery/
│ ├── faa-scraper.ts # FAA Part 135 registry HTML scraper
│ └── pipeline.ts # Scrape → store → email orchestration
├── email/
│ ├── resend.ts # Resend API client with stub mode
│ └── templates.ts # Branded HTML email templates
├── outreach/
│ └── engine.ts # Matching algorithm, RFQ generation, Resend delivery
└── payments/
├── config.ts # Mode detection, fee calculation
├── stripe.ts # Stripe client
├── service.ts # Checkout, fulfillment, refunds, Connect, webhook handling
└── __tests__/ # Vitest suite
Fifteen tables in Postgres:
| Table | Purpose |
|---|---|
users | App profile for every account (customer, operator, admin): role, phone, auth_id link to Neon Auth, is_demo. password_hash is used only by the development sign-in. |
operators | Linked to a user. Company name, certificate, approval status, contact preferences, market/fleet/safety profile for matching. |
aircraft | Operator fleet inventory. Tail number, type, capacity, range, year, serial/manufacturer/model, last FAA registry record (faa_record JSON, faa_checked_at). |
aircraft_photos | Photos per aircraft: JPEG/PNG/WebP bytes and thumbnail (BYTEA), position (0 = cover), dimensions, caption. |
booking_requests | Customer trip requests. Status lifecycle: open → quoted → booked → completed (or cancelled). |
booking_legs | Individual flight legs within a request. Origin, destination, date, time. |
quotes | Operator price submissions. Linked to request + operator + optional aircraft. Status: pending → accepted/rejected/withdrawn. |
outreach_log | Every RFQ dispatched to an operator. Method, match score, RFQ content, delivery status. Unique on (request_id, operator_id). |
discovered_operators | FAA-scraped operators pending outreach. Certificate number (unique), contact info, discovery status. |
payments | One row per checkout attempt. Amount, platform fee, operator payout, refunded total, Stripe session and PaymentIntent IDs, status. |
refunds | Ledger of refunds issued from the app. |
payment_events | Stripe webhook event IDs that have been processed, used for idempotency. |
trip_shares | Private trip sheet links for booked charters. Revocable; expire 30 days after the last leg. |
operator_staff | An operator's staff roster: contact details, alert channels and which events each person receives. |
staff_notifications | One row per staff alert per channel. Unique on (staff_id, event_key, channel) so alerts are never duplicated. |
The operators table also stores the Stripe Connect account ID, capability flags, and an optional fee override.
src/lib/db/index.ts picks the database:
DATABASE_URL | Database |
|---|---|
| Set | Postgres via pg (production: Neon's pooled connection string, injected by the Vercel integration) |
| Unset, development | Embedded PGlite (Postgres compiled to WASM) in data/pglite. Nothing to install. |
| Unset, production | Startup error |
schema.ts) is applied on the first query after each cold start. Every statement is IF NOT EXISTS, and an advisory lock stops concurrent cold starts from colliding. Schema changes must be additive and idempotent.? placeholders, which are rewritten to $1, $2, …. Do not put a literal ? in SQL text.COUNT/SUM/BIGINT values come back as JS numbers. Timestamps (TIMESTAMPTZ) come back as ISO 8601 strings. Money is stored in cents as BIGINT.SELECT … FOR UPDATE). Stripe webhooks for the same request on different serverless instances then run one at a time instead of deadlocking or double-booking.git clone https://github.com/Add-Homonym/bespoke.flights.git
cd bespoke.flights
npm install
cp .env.example .env.local # fill in values
npm run seed # loads test data into the local PGlite database
npm run dev # http://localhost:3000
npm test # Vitest suite (in-process PGlite)
No database setup is needed locally. To develop against Neon instead, pull the project's env vars (vercel env pull .env.local). Prefer a Neon development branch over production. The seed script refuses to run when DATABASE_URL is set unless you pass SEED_DATABASE=yes, because it creates accounts with a known password.
To run the tests against a real Postgres server (tables are truncated):
TEST_DATABASE_URL=postgres://… npm run test:pg
Created by npm run seed locally, or automatically in test mode. All use password password123:
| Role | Notes | |
|---|---|---|
[email protected] | Admin | Full platform access |
[email protected] | Customer | Has a 3-leg east coast request with 2 quotes |
[email protected] | Customer | Has a LAX↔HNL request, no quotes yet |
[email protected] | Operator | Elite Air Charter — approved, full profile, G650 + Citation X |
[email protected] | Operator | SkyBridge Aviation — approved, Global 7500 + Phenom 300E |
[email protected] | Operator | Pacific Wings — approved, Hawaii specialist |
[email protected] | Operator | Westside Jets — pending approval, no profile |
| Variable | Required | Description |
|---|---|---|
NEON_AUTH_BASE_URL | Production | Neon Auth endpoint (Neon console → Auth). Leave empty locally to use bcrypt accounts and a JWT cookie instead. |
NEON_AUTH_COOKIE_SECRET | Production | Signs Neon Auth session cookies (32+ random characters). |
JWT_SECRET | Development | Signs the development session cookie. Must be 16+ characters and not change-me; without it, development uses a random per-process key (sessions end on restart) and production refuses to sign. |
RESEND_API_KEY | No | Resend API key. Without it, emails log to stdout. |
INBOUND_EMAIL_SECRET | No | Bearer token for POST /api/webhooks/inbound-email. When set, RFQ emails offer reply-by-email quoting. |
EMAIL_REPLY_DOMAIN | No | Domain for quotes+<token>@ reply addresses. Defaults to the domain of EMAIL_FROM. |
EMAIL_FROM | No | Verified sender address (default: [email protected]) |
CRON_SECRET | Production | Secures the /api/cron/weekly-discovery and /api/cron/daily-lifecycle endpoints |
STRIPE_SECRET_KEY | Production | Stripe secret key. Without it, payments run in stub mode (development) or are disabled (production). |
STRIPE_WEBHOOK_SECRET | Production | Signing secret for the platform webhook endpoint |
STRIPE_CONNECT_WEBHOOK_SECRET | Production | Signing secret for the connected-accounts webhook endpoint |
PLATFORM_FEE_BPS | No | Fallback platform commission in basis points (default 500 = 5%) until set at /admin/settings |
APP_URL | Production | Public base URL for Stripe redirect URLs and links in emails and texts. In production the request's Host header is never used; without APP_URL the app falls back to Vercel's and otherwise fails the request. |
Vercel is the only host. netlify.toml tells Netlify to skip every build of this repository; Netlify's dashboard connection can also be removed.
DATABASE_URL (pooled) and related PG*/POSTGRES_* variables. The app only reads DATABASE_URL.Optional: enable Neon's preview-branch option in the integration so each preview deployment gets its own database branch instead of sharing production data.
Set NEON_AUTH_BASE_URL, NEON_AUTH_COOKIE_SECRET, CRON_SECRET (required for the scheduled discovery run), APP_URL, and the Stripe, Resend and Twilio variables from the table above.
vercel deploy
The cron schedules in vercel.json run the discovery pipeline every Monday at 18:00 UTC (8:00 AM HST) and the quote/trip lifecycle job daily at 10:00 UTC. Vercel Cron requires a Pro plan.
To test the cron endpoint locally (set CRON_SECRET in .env.local):
curl -X POST http://localhost:3000/api/cron/weekly-discovery \
-H "Authorization: Bearer $CRON_SECRET" \
-H "Content-Type: application/json" \
-d '{"states": ["HI"], "emailsPerRun": 5}'
bounced automatically.no_email operators.phone for Bland.ai / Vapi voice agent integration.TripDetailssrc/lib/trip-format.tsMatching runs on submit. The matching engine immediately runs, scoring all approved operators on market coverage, fleet fit, safety rating, and past performance. The top 10 matches receive the RFQ automatically.
View request status at /requests/[id]. The Operator Outreach section shows which operators were contacted, their match scores, and whether they've responded. Quotes appear below, sorted by price.
Accept & pay. Choosing a quote opens Stripe Checkout (card or US bank account). When payment succeeds, that quote is accepted, all others are rejected, and the request is marked booked. See Payments.
trip_shares table).Dashboard at /operator/dashboard (also /operator, the operator home after sign-in). Summary boxes link to each section: inbound requests (awaiting / quoted / missed, requests per week), all quotes (by status, win rate), business earned (payouts net of refunds, by month), team (staff on alerts, email/text channels), and aircraft (seats, cover photos, registrations to check). Below them is the company board (/operator/board redirects here). It is a live view of every charter that concerns the company. It refreshes every 15 seconds without a reload, pausing while the tab is hidden. It has four sections:
Charters that appear or change while the board is open are marked New or Updated, and the tab title shows the count.
Staff alerts at /operator/team. Add up to 50 staff, each with an email and/or a mobile number. Choose per person:
Alerts go out automatically when a payment completes, when a booking is cancelled (by the customer or a full refund) and when the matching engine sends the company an RFQ. Booking alerts include the private trip sheet link. Each person gets each alert once per channel: redelivered webhooks do not duplicate it, and failed sends are retried on the next trigger (staff_notifications table). Send test checks a person's setup.
/board/<token> URL of the company board for dispatch screens or staff without an account. It can be replaced or turned off at any time.
8b. Autopilot at /operator/autopilot, a cockpit-style console. When engaged, every request matched to the operator gets a drafted quote (src/lib/quotes/autopilot.ts): the smallest aircraft that seats the group and covers the longest leg, leg hours from distance and cruise speed, positioning from and back to the home base, then the operator's margin, fuel surcharge, daily minimum, crew overnights, landing per leg, catering per guest per leg, excise tax and segment fees through the quote worksheet. Drafts are stored in quote_drafts with notes for the reviewer (no rate on file, group larger than the fleet, range short of a leg) and are never visible to travelers. The console streams incoming requests every 8 seconds with each one's draft or quote; Review opens the inbound form prefilled, where a staffer adjusts and presses Approve and submit quote. Requests can also be drafted on demand with Draft with Autopilot. Engaging Autopilot drafts the requests already waiting. Routes: GET/POST/DELETE /api/booking-requests/[id]/draft. Learning (src/lib/quotes/learning.ts, on by default, LEARN switch on the console): every quote submitted or revised with the worksheet updates weighted averages of the operator's margin, fuel surcharge, daily minimum, crew overnight, landing per leg, catering per guest per leg, tax and segment fee; per aircraft, the hourly rate and how entered flight times compare with the distance estimate; and how far staff move the final price from the worksheet total. Drafts use learned values once they exist (shown as LRN on each instrument and listed on the draft), an aircraft with no rate on file takes the rate staff used, and open drafts are redrafted after each submission. Stored in autopilot_learning; RESET on the console (DELETE /api/operator/autopilot/learning) returns to the setpoints.Manage fleet at /operator/fleet:
aircraft_photos and served from /api/aircraft-photos/<id> with immutable caching. The first photo is the cover.src/lib/faa/registry.ts) and fills blank fields (serial, manufacturer, model, year). The stored record shows registration status and expiry, airworthiness, engines and registered owner, and warns when the registry's aircraft does not match the entered type. Accident and incident history is with the NTSB, not the FAA; the record links to the NTSB CAROL search (the NTSB API needs a developer key).Set up payouts at /operator/settings → Payouts. This opens Stripe Connect Express onboarding. Customers cannot pay for an operator's quotes until onboarding is complete.
operator_boards | The private staff board link token for each operator. |
rate_limits | Fixed-window request counters, keyed by scope and user or IP. |
VERCEL_PROJECT_PRODUCTION_URLTWILIO_ACCOUNT_SID | No | Twilio account for RFQ texts and staff text alerts. Without the Twilio variables, texts log to stdout. |
TWILIO_AUTH_TOKEN | No | Twilio auth token |
TWILIO_MESSAGING_SERVICE_SID | No | Twilio Messaging Service to send from (or set TWILIO_FROM_NUMBER) |
TWILIO_FROM_NUMBER | No | Sending number in E.164, used when no Messaging Service is set |
APP_TEST_MODE | No | true simulates all payments, shows a test banner, and loads demo data into an empty database. Demo accounts (is_demo) cannot sign in once this is off. Refused alongside a live Stripe key. |
DATABASE_URL | Production | Postgres connection string. Set automatically by the Vercel Neon integration (pooled). |
DATABASE_POOL_MAX | No | Max connections per function instance (default 5) |
PGLITE_DIR | No | Local PGlite data directory when DATABASE_URL is unset (default data/pglite; memory for in-memory) |