Ecommerce Template
Next.js·React 등으로 만든 최신 전자상거래 템플릿
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗A buy-and-sell marketplace for students of one college. Members verify with their college email, list second-hand items with photos, negotiate in threaded inquiries with offers, and reserve and complete sales through an enforced listing lifecycle.
Students regularly need affordable second-hand textbooks, calculators, furniture and electronics, and the students who own them are often in the same building. General marketplaces are full of strangers and sellers who are far away. WhatsApp groups can't keep track of who reserved what, so items get promised twice, buyers keep asking "is this still available?", and the price someone agreed to is lost in the scroll.
Students of a single college, verified by their college email address (e.g. @sst.scaler.com). Each student can act as a buyer and a seller.
CampusCart makes the marketplace software rather than a chat thread:
EMAIL_VERIFICATION=required) can additionally prove inbox ownership.EMAIL_VERIFICATION. The live deployment runs with it off, so accounts are active right after sign-up.tsvector column with a GIN index). Filter by category, condition, price range and status (Available by default, optionally Reserved). Sort by best match, newest or price, with pagination. All state lives in the URL, so searches can be shared and survive a refresh.| Piece | Why |
|---|---|
| Next.js 16 (App Router, TypeScript) | One codebase for the UI and the API (Route Handlers on the Node runtime), plus Server Components that read the database directly. |
| PostgreSQL on Neon + Prisma 7 | Relational data with real constraints (unique conversation per buyer, one pending offer per conversation) and built-in full-text search. Prisma provides typed queries and migrations, and connects through @prisma/adapter-pg. |
| Vercel Blob | Photo storage with direct browser uploads, so large files never pass through the serverless functions. Authenticates with Vercel OIDC, so no long-lived token is needed. |
| Auth.js (NextAuth v5) | Credentials login with an encrypted JWT in an httpOnly cookie. |
| Zod | One schema per form, shared by the client (instant field errors) and the server (the source of truth). |
| Tailwind CSS | Fast, consistent styling with design tokens for light and dark mode. |
| Vitest | Unit tests for the pure business rules. |
flowchart LR
subgraph Browser["Browser (phone or laptop)"]
UI["React UI<br/>Server + Client Components"]
IMG["Image compressor<br/>(canvas → WebP)"]
POLL["Thread poller<br/>(every 4 s)"]
end
subgraph Vercel["Vercel: Next.js 16 (Node runtime)"]
PROXY["proxy.ts<br/>optimistic session check"]
PAGES["Server Components<br/>(read via services)"]
API["Route Handlers /api/*<br/>Zod → service → AppError → HTTP status"]
AUTH["Auth.js credentials<br/>JWT in httpOnly cookie"]
subgraph Core["src/lib"]
AUTHZ["authz.ts<br/>requireVerifiedUser · assertOwner · assertParticipant"]
DOMAIN["domain/<br/>listing-state · offer-rules (pure)"]
SVC["services/<br/>listings · lifecycle · offers · conversations · search · images"]
end
end
DB[("Neon Postgres<br/>Prisma 7 + adapter-pg<br/>FTS GIN index · partial unique index")]
BLOB[("Vercel Blob<br/>listing photos")]
SMTP["SMTP<br/>verification codes"]
UI --> PROXY --> PAGES
UI -- "fetch JSON" --> API
POLL -- "GET ?after=" --> API
PAGES --> AUTHZ
API --> AUTHZ --> SVC
AUTH --> DB
SVC --> DOMAIN
SVC -- "pooled URL" --> DB
API -- "issueSignedToken (OIDC)" --> BLOB
IMG -- "presigned PUT" --> BLOB
SVC -- "head / del" --> BLOB
SVC --> SMTP
stateDiagram-v2
[*] --> Draft: seller saves details
Draft --> AVAILABLE: publish (needs ≥1 photo)
AVAILABLE --> RESERVED: seller reserves / offer accepted
RESERVED --> AVAILABLE: seller cancels / buyer backs out / 48h expiry (lazy)
RESERVED --> SOLD: seller marks sold → Transaction
AVAILABLE --> WITHDRAWN: owner withdraws
RESERVED --> WITHDRAWN: owner withdraws
WITHDRAWN --> AVAILABLE: owner relists
SOLD --> [*]
Every arrow is one entry in TRANSITIONS (src/lib/domain/listing-state.ts). It runs as a conditional atomic update and writes a ListingEvent (who, from, to, when), which the seller sees as a timeline. Offers follow their own rules (offer-rules.ts): PENDING → ACCEPTED | REJECTED | COUNTERED | WITHDRAWN | EXPIRED.
User · VerificationToken · Listing (+ generated searchVector) · ListingImage · Conversation (unique per listing + buyer) · Message (TEXT / OFFER / SYSTEM) · Offer (one PENDING per conversation) · Transaction (unique per listing) · ListingEvent · RateLimit. See prisma/schema.prisma and the init migration for the indexes behind search, filters and inbox queries.
src/lib/domain: pure rules (listing state machine, offer rules), unit-tested.src/lib/services: database work for each feature. All business rules live here, not in routes.src/app/api/**: thin Route Handlers. They validate with Zod, call a service, and map AppErrors to real HTTP status codes (400/401/403/404/409/429).src/lib/authz.ts: requireVerifiedUser, assertListingOwner and the other guards. They re-read the user from the database on every request, so a stale session can't bypass verification.Requirements: Node 20+ and a Postgres database (Neon recommended).
git clone https://github.com/parvcsinclined/campuscart.git
cd campuscart
npm install # also runs `prisma generate`
cp .env.example .env # fill in values (see below)
# or, if you have access to the Vercel project:
vercel link && vercel env pull .env.local
npx prisma migrate deploy # create tables, search index and constraints
npm run db:seed # demo accounts
npm run db:seed:demo # optional: 12 demo listings with photos + sample negotiations
npm run dev # http://localhost:3000
Useful scripts: npm run lint, npm run typecheck, npm test (Vitest), npx vitest run src/lib/validation (a single test folder).
With EMAIL_VERIFICATION=required and no SMTP configured, verification codes are printed in the dev server log.
| Name | Description |
|---|---|
DATABASE_URL | Neon pooled connection string, used by the app at runtime. |
DATABASE_URL_UNPOOLED | Neon direct connection string, used by Prisma migrations. |
AUTH_SECRET | Secret for encrypting session cookies (npx auth secret). |
ALLOWED_EMAIL_DOMAINS | Comma-separated, exact-match email domains allowed to sign up. |
EMAIL_VERIFICATION | off (default): no email code, so accounts are active immediately. required: users must enter a code sent over SMTP before listing, messaging or making offers. |
BLOB_STORE_ID | Vercel Blob store ID (set automatically when the store is connected). |
VERCEL_OIDC_TOKEN | Short-lived OIDC token used by Blob. Injected on Vercel; locally it comes from vercel env pull. |
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, EMAIL_FROM | SMTP settings for verification emails (e.g. Gmail with an App Password). Only needed when EMAIL_VERIFICATION=required. |
Deployed on Vercel from the main branch: every push builds and deploys. Neon Postgres and Vercel Blob are attached from the Vercel project's Storage tab. Migrations run with npx prisma migrate deploy against DATABASE_URL_UNPOOLED.
Demo accounts (pre-verified, on the demo domain example.edu):
| Role | Password | |
|---|---|---|
| Seller | [email protected] | CampusCart-demo-2026 |
| Buyer | [email protected] | CampusCart-demo-2026 |
| Second buyer (for competing offers) | [email protected] | CampusCart-demo-2026 |
put only, JPEG/PNG/WebP, at most 5 MB, valid for 10 minutes. The browser resizes the photo to at most 1600 px WebP and uploads it straight to Blob. A separate attach call then re-checks ownership and asks Blob for the object's real type and size before saving it. Upload webhooks can't reach localhost, which is why attaching is an explicit step.src/lib/domain/listing-state.ts). The service executes a planned transition as UPDATE "Listing" … WHERE id = ? AND status = <expected>. If two buyers' reservations race, exactly one update matches and the other gets 409 Conflict; this was verified with two simultaneous requests. Accepting an offer updates the offer (WHERE status = 'PENDING') and reserves the listing in the same database transaction, so neither can happen without the other.ON "Offer"("conversationId") WHERE status = 'PENDING') backs the application check, so a double-click or two tabs can't create two pending offers.reservedUntil. Whenever listings are read or acted on, overdue ones are expired through the same conditional transition, which records a ListingEvent with the system as actor and posts a message to the buyer. There's no cron or worker to deploy or monitor, and the conditional update makes concurrent expiry safe.?after=<last message time> every 4 s and gets only new messages plus the current listing and offer state. This is simple and robust on flaky phone connections, and costs nothing when the tab is hidden.GENERATED ALWAYS … STORED tsvector (title weighted above description) stays in sync automatically, and a GIN index serves the @@ match. User text is reduced to letters and digits and turned into a prefix query (casio:* & calc:*), so input can't inject tsquery syntax. Filters, sort and COUNT(*) OVER() pagination run in one parameterised query.INSERT … ON CONFLICT DO UPDATE per check. In-memory counters don't work across serverless instances, and this avoids adding Redis.Demo listing photos are generated illustrations (rendered with sharp by prisma/seed-demo.ts). Uploads by real users store their actual photos.
Email-code verification is switched off in the live deployment (no SMTP account configured). Sign-up is restricted to campus email domains, but owning the inbox isn't proven. Setting EMAIL_VERIFICATION=required plus the SMTP variables turns it back on without code changes.
One database serves development and production for this submission. A real deployment would use a separate Neon branch per environment.
New messages arrive within about 4 seconds (polling), not instantly.
Reservation expiry is applied when someone next views or acts on the listing, not at the exact 48-hour mark. The effect is the same from every user's point of view.
Pages under the app shell show a streaming loading skeleton. As a result, "not found" pages render the 404 UI with noindex, but the HTTP status is 200 rather than 404. API routes always return proper status codes.
CampusCart was built from scratch for this project. It uses the open-source libraries listed under Tech stack, but it isn't based on an existing template or tutorial project.