Every piece has a story.
A multi-vendor marketplace for thrift and vintage clothing. Customers shop across many independent vendors, vendors run their own shops with AI-assisted listing tools, and admins moderate the platform.
Built as a modular monolith with .NET 10 on the backend and Next.js 16 on the frontend. The full stack runs in Docker with one command.
📊 Project Presentation — overview deck
- Highlights
- Tech Stack
- Architecture
- Roles
- User Journeys
- Features
- AI Features
- Demos
- Quick Start
- Environment Variables
- Project Structure
- API Surface
- Database
- Order State Machine
- Coding Standards
- Testing
- Deployment
- Gotchas
- Multi-vendor cart and checkout. Customers add items from multiple shops; checkout produces one order per vendor and a single Stripe Checkout session.
- Stripe-powered payments. Hosted Checkout, webhook-confirmed orders, idempotent state transitions. Test mode only.
- Three Claude-powered AI features. Listing description generator, image-quality coaching, and an AI chatbot search that extracts intent and returns matching products.
- Full vendor lifecycle. Application → admin approval → shop profile (logo, banner, copy) → listings → orders → reviews.
- Reviews with AI summaries. Sub-rating averages, photo uploads, and Claude-generated highlight summaries cached in Redis.
- Admin dashboard. Vendor approvals, product moderation, orders view, stats, trends, and composition charts.
- Flat, warm design system. Tailwind v4 tokens, shared primitives, framer-motion via reduced-motion-aware wrappers, Phosphor duotone icons.
| Layer | Tech |
|---|---|
| Backend | .NET 10 Minimal APIs |
| ORM | EF Core 9 + PostgreSQL 16 |
| Cache | Redis 7 |
| Storage | AWS S3 (BucketOwnerEnforced) |
| Auth | ASP.NET Identity + JWT Bearer (access + rotating refresh tokens) |
| Validation | FluentValidation |
| AI | Anthropic Claude API (claude-sonnet-4-5) for descriptions, search, review summaries · Google Gemini (gemini-2.5-flash-image) for image enhancement · Replicate (virtual try-on model) |
| Payments | Stripe Checkout (test mode) |
| Frontend | Next.js 16 (App Router, Turbopack) |
| Styling | Tailwind v4 + @base-ui/react + CVA |
| Motion | framer-motion via shared primitives (respects prefers-reduced-motion) |
| Icons | lucide-react (UI chrome) + @phosphor-icons/react duotone (categories) |
| Fonts | Playfair Display (headings) + Inter (body) via next/font/google |
| State | Zustand |
| Data Fetching | TanStack Query |
| Forms | React Hook Form + Zod |
| Testing | xUnit + NSubstitute + FluentAssertions, Testcontainers, Playwright |
Modular monolith. Each module owns its domain, exposes a thin endpoint layer, and communicates with other modules through interfaces in Preloved.Shared — never via direct project references.
Preloved.slnx
└── src/
├── Preloved.API/ ← Composition root, Minimal API host
├── Preloved.Modules.Identity/ ← Auth, JWT, refresh tokens, roles
├── Preloved.Modules.Vendors/ ← Profiles, applications, dashboards
├── Preloved.Modules.Products/ ← Listings, images, status machine
├── Preloved.Modules.Orders/ ← Cart, checkout, order state
├── Preloved.Modules.Payments/ ← Stripe Checkout sessions + webhook
├── Preloved.Modules.Reviews/ ← Reviews, photo uploads, AI summaries
├── Preloved.Modules.AI/ ← All Claude API integrations
├── Preloved.Infrastructure/ ← EF Core, Redis, S3, seeders
└── Preloved.Shared/ ← Result<T>, IModule, contracts
Module pattern. Every module implements IModule:
public interface IModule
{
IServiceCollection RegisterServices(IServiceCollection services, IConfiguration config);
IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder app);
}Cross-module contracts. Lookups like IVendorStatusLookup live in Preloved.Shared.Interfaces so the Products module can gate on vendor status without referencing the Vendors project.
| Role | Capabilities |
|---|---|
Customer |
Browse, search (text + AI), save items, multi-vendor cart, checkout, review purchases |
Vendor |
Apply, manage shop profile (logo, banner, copy), list products, view orders, dashboard analytics |
Admin |
Approve/reject vendors, feature shops, moderate listings, moderate reviews, view platform stats |
- Browse by category, condition, size, brand, price
- Save items, multi-vendor cart with per-vendor breakdown at checkout
- Order tracking with real-time status, batch confirmation page across multiple orders
- Reviews with sub-ratings (item accuracy, communication, shipping) and up to 3 photos
- Application flow with sample images and structured questionnaire
- Re-apply after rejection (same endpoint flips
Statusback toPending) - Shop profile editor: text fields, logo upload, banner upload (5 MB cap, JPG/PNG/WebP)
- Listing CRUD with image upload, drag-to-reorder, lifestyle vs. product image flags
- Listing status state machine (
Draft ↔ Active → Sold) - Per-vendor order list and dashboard analytics
- Multi-page dashboard at
/admin(sidebar + routed sections) - Pending and approved vendor tabs (Pending first)
- Product moderation with detail drawer
- Orders view (read-only), platform stats, trends, composition charts
- Featured-vendor toggle (invalidates Redis recommendation cache immediately)
- Review deletion (purges cached summary)
All three are powered by the Anthropic Claude API.
Input: keywords, category, condition, brand, price. Output: 2–3 paragraph listing description, warm and personal in tone. Frontend: "Write for me" button on the new listing form streams text into the textarea.
Input: multipart image upload.
Output: { photoScore, improvements[], lightingTip, enhancedPromptSuggestion }.
Frontend: auto-triggers after upload, renders a "Photo Tips" side panel with a numeric score and concrete suggestions. Suggestions only — no actual image manipulation.
Input: user message + last 6 messages of context (3 exchanges).
Output: { message, searchParams, followUpQuestion } plus matching products.
Frontend: floating chat widget that renders product result cards inline.
Returns Claude-generated highlights plus sub-rating averages. Cached in Redis under reviews:summary:product:{productId} for 7 days; the write path and admin delete both invalidate the key.
- Listing Description Generator — https://www.loom.com/embed/51894d98cd73467b84fcaee1cb1d0ebf
- AI Image Enhancement Tips — https://www.loom.com/embed/afd93d17850c40e4b4f7bd098c8185b0
- AI Chatbot Product Search — https://www.loom.com/embed/9e939e7a5b98431ca0f0330071a14ea8
- Lifestyle Shots on Demand — https://www.loom.com/embed/04711631c30247d0892af600d6e8e352
- See It On You Before You Buy (virtual try-on) — https://www.loom.com/embed/ff44c7ee302445ac837b9ade57326281
Docker is the only path. Postgres, Redis, the API, and the web frontend all come up together.
cp .env.docker .env # First time only — fill in real values
docker compose up -d # Start the full stack
docker compose down # Stop (data persists in volumes)
docker compose up -d --build # Rebuild after code changes
docker compose exec postgres psql -U postgres -d preloved # DB shellStripe webhook forwarding for local development:
docker compose --profile stripe up stripe-cli
# Copy the printed whsec_... into .env as STRIPE_WEBHOOK_SECRET, then restart apiService ports. API on 8080, frontend on 3000, Postgres on 5432, Redis on 6379. Services reach each other inside the network using their service names (postgres, redis, api).
Demo card. 4242 4242 4242 4242, any future expiry, any CVC.
# Backend
ConnectionStrings__Postgres=Host=localhost;Database=preloved;Username=postgres;Password=postgres
ConnectionStrings__Redis=localhost:6379
Anthropic__ApiKey=sk-ant-...
Anthropic__Model=claude-sonnet-4-5
Gemini__ApiKey=...
Gemini__Model=gemini-2.5-flash-image
Replicate__ApiToken=r8_...
Replicate__TryOnModel=cuuupid/idm-vton:<version-hash>
AWS__S3__AccessKeyId=...
AWS__S3__SecretAccessKey=...
AWS__S3__BucketName=preloved-images
AWS__S3__Region=us-east-1
Jwt__Secret=your-super-secret-key-at-least-32-chars
Jwt__Issuer=preloved-api
Jwt__Audience=preloved-web
Jwt__AccessTokenExpiryMinutes=15
Jwt__RefreshTokenExpiryDays=7
Stripe__SecretKey=sk_test_...
Stripe__WebhookSecret=whsec_...
# Frontend (.env.local)
NEXT_PUBLIC_API_URL=https://localhost:7001
NEXT_PUBLIC_S3_PUBLIC_URL=https://{bucket}.s3.{region}.amazonaws.com
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_....env is in .gitignore. Use Stripe test keys only (sk_test_ / pk_test_).
preloved/
├── src/ ← .NET solution (modular monolith)
│ ├── Preloved.API/ ← Composition root, Program.cs, appsettings, Dockerfile
│ ├── Preloved.Infrastructure/ ← EF Core DbContext, Migrations, S3 storage adapter
│ ├── Preloved.Shared/ ← Cross-cutting: caching, shared interfaces, models
│ └── Preloved.Modules.*/ ← Feature modules — each ships its own
│ │ DTOs, Models, Services, Validators, *Module.cs endpoints
│ ├── Identity/ ← Auth, users, JWT, refresh tokens
│ ├── Products/ ← Listings, categories, search
│ ├── Vendors/ ← Vendor shops and profiles
│ ├── Orders/ ← Cart, checkout, order state machine, admin views
│ ├── Payments/ ← Stripe Checkout + webhook handling
│ ├── Reviews/ ← Product reviews + AI summary
│ └── AI/ ← Claude / Gemini / Replicate clients, prompts
├── tests/
│ ├── Preloved.UnitTests/ ← xUnit + NSubstitute + FluentAssertions
│ └── Preloved.IntegrationTests/← WebApplicationFactory + Testcontainers
├── frontend/preloved-web/ ← Next.js 16 (App Router)
│ ├── src/app/ ← Routes
│ ├── src/components/ui/ ← Shared primitives (Button, Card, ...)
│ ├── src/components/brand/ ← Domain components (ProductCard, ...)
│ ├── src/components/motion/ ← FadeIn, Stagger, HoverLift wrappers
│ └── e2e/ ← Playwright specs
├── scripts/ ← One-off SQL seeds and utilities
├── docker-compose.yml ← Full stack
└── .env.docker ← Env template (committed)
/api/auth/* register, login, refresh, logout, me
/api/products/* CRUD, images, save/unsave, trending,
related (by id and by cart),
status transition (Draft↔Active→Sold)
/api/vendors/* list (public), featured (public), apply,
public profile, my dashboard, my application,
shop profile edit (text + logo + banner uploads),
analytics
/api/orders/* place, list, status updates, /orders/mine,
/orders/{id}/items/{itemId}/review
/api/cart/* add, remove, update quantity, clear
/api/payments/* Stripe Checkout session, webhook handler
/api/admin/* vendor approval, paginated approved/pending lists,
featured toggle, product moderation, orders list,
stats, trends, composition
/api/admin/reviews/* delete (moderation)
/api/ai/* enhance-image, generate-description, search
/api/reviews/* image uploads
/api/products/*/reviews list, summary (AI highlights + sub-rating averages)
/api/vendors/*/reviews list, stats
Response envelope. Every endpoint returns { success, data, message, errors }.
Pagination. Admin list endpoints accept ?page=N&pageSize=M clamped 1–50. The frontend infers hasNextPage from rows.length === pageSize.
Users — id, email, username, password_hash, role, is_active
RefreshTokens — id, user_id, token, expires_at, is_revoked
VendorProfiles — id, user_id, shop_name, status, rating, is_featured,
application_answers (jsonb), sample_image_urls (text[]),
rejection_reason
Categories — id, name, slug, icon_emoji
Products — id, vendor_id, category_id, title, description, price,
condition, size, brand, status, tags[]
ProductImages — id, product_id, url, is_enhanced, is_lifestyle,
is_primary, sort_order
SavedItems — user_id, product_id (composite PK)
CartItems — id, user_id, product_id, quantity, added_at
Orders — id, customer_id, vendor_id, status, total_amount,
shipping_address (jsonb), stripe_payment_intent_id
OrderItems — id, order_id, product_id, title (snapshot),
price (snapshot), quantity
Reviews — id, order_id, reviewer_id, vendor_id, rating, comment,
sub-ratings, photos
SearchSessions — id, user_id, session_id
SearchMessages — id, session_id, role, content
Conventions worth knowing:
Products.VendorIdandReviews.VendorIdboth storeUser.Id(notVendorProfile.Id). Documented inProductModule.cs.VendorProfile.ApplicationAnswersis JSONB with camelCase keys matching theVendorApplicationAnswersDTO.- S3 paths:
vendors/{userId}/{logo|banner}/{guid}{ext},reviews/{reviewId}/{guid}{ext}. Fresh GUID per upload, so no CDN cache-busting needed.
Pending → Confirmed → Shipped → Delivered → Completed
Pending → Cancelled
Confirmed → Cancelled
Two confirmation paths:
- Stripe webhook:
checkout.session.completedflipsPending → Confirmedand stampsStripePaymentIntentId. Idempotent. - Manual vendor transition via
PUT /api/orders/{id}/status.
Result<T>everywhere. Services never throw — they returnResult<T>.Success(data)orResult<T>.Failure(error).- Endpoints are thin. All logic in services. Endpoints map
Result<T>to HTTP responses. - FluentValidation, not data annotations.
- Cancellation tokens on all async methods.
- Standard envelope:
{ success, data, message, errors }on every response.
- Design tokens only. Defined in
src/app/globals.cssvia Tailwind v4@theme. No inline hex values. Semantic tokens (bg-background,text-foreground,bg-primary, ...) and brand aliases (bg-cream,text-espresso,text-sage, ...). - Shared primitives. Use
Button,Input,Card,Badge,Skeletonfrom@/components/ui/*. UseLogo,ProductCard,OrderStatusBadgefrom@/components/brand/*. - Motion primitives. Use
FadeIn,Stagger+StaggerItem,HoverLiftfrom@/components/motion/*— never raw framer-motion in pages. They respectprefers-reduced-motion. - Icons. lucide-react for UI chrome;
@phosphor-icons/reactduotone for categories and decorative use. No emojis in UI copy. - Loading skeletons, never blank states.
- Zod on every form, toast feedback on every mutation.
- Cards:
rounded-2xl border border-border bg-card p-5 - Buttons: default rounded-full via
<Button>; usebuttonVariants()on<Link>(base-ui Button has noasChild) - Section eyebrows:
text-xs font-medium uppercase tracking-[0.18em] text-taupe
- Add features not in the PRD without asking
- Use data annotations for validation (use FluentValidation)
- Throw exceptions in services (use
Result<T>) - Hardcode colors (use tokens)
- Use gradients, shadows, or blur (flat design)
- Use "used" or "secondhand" in copy — always "preloved"
- Use live Stripe keys — test mode only
Three layers:
| Type | Tool | Speed | When |
|---|---|---|---|
| Unit | xUnit + NSubstitute + FluentAssertions | Fast | Every service, validator, state machine |
| Integration | WebApplicationFactory + Testcontainers | Medium | Every endpoint group, full flows |
| E2E | Playwright (TypeScript) | Slow | Critical user flows |
Non-negotiables:
- Order state machine: 100% unit coverage
- Validators: 100% unit coverage
- Every endpoint: at least one integration test (happy path + auth failure)
- AI services: always use
FakeAnthropicClientin integration tests — no real API calls - Real Postgres + Redis via Testcontainers — no SQLite, no DB mocking
- E2E uses Stripe test card
4242 4242 4242 4242
dotnet test tests/Preloved.UnitTests # fast, run constantly
dotnet test tests/Preloved.IntegrationTests # needs Docker
npx playwright test # needs full stack running
dotnet watch test --project tests/Preloved.UnitTestsCommit rule. Never commit a new service or endpoint without at least a unit test for it.
Production runs on Railway with four services (Postgres, Redis, API, web). Configuration uses hierarchical naming (Anthropic__ApiKey, Stripe__SecretKey, ...).
The non-obvious things you will trip over:
Products.VendorId/Reviews.VendorIdstoreUser.Id, notVendorProfile.Id. On vendor profile pages useuseProducts({ vendorId: vendor.userId })— not the URLidparam.- Discovery endpoints are Redis-cached for 5 minutes under the
recs:prefix. Any write that should reflect immediately must invalidate its key (the admin featured toggle already does). - All
/api/vendors/memutations gate onStatus == "Approved"at the service level. Endpoint-levelRequireAuthorization("VendorOrAdmin")is defence-in-depth, not the primary check. - S3 bucket is
BucketOwnerEnforced— never set aCannedACLon uploads. Visibility is bucket-policy controlled. - Product status is one-hop only.
Draft ↔ Active,Active → Sold.Soldis terminal. Don't add transitions by special-casing callers. DatabaseSeederhas two phases.SeedAsyncshort-circuits when products exist, then callsTopUpDemoDataAsyncto re-stagger demo timestamps and top up orders. Both top-up steps are guarded and safe to run on every startup.- Stripe webhook returns 503 if
Stripe:WebhookSecretis empty — makes it obvious when env hasn't propagated. HandleCheckoutCompletedAsyncis idempotent (only acts onPendingorders), so webhook re-deliveries are safe. Vendors can still manually transitionPending → Confirmed— both paths coexist.- Cart clears immediately on order creation, not after payment. Reverted from a brief experiment with deferred clearing.
- Multi-vendor checkout creates one order per vendor but a single Stripe session and a single batched success/cancel page.
Private project.



