Skip to content

Arobce/preloved

Repository files navigation

Preloved

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


Table of Contents


Highlights

  • 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.

Tech Stack

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

Architecture

Preloved system architecture

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.


Roles

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

User Journeys

Customer

Customer journey

Vendor

Vendor journey

Admin

Admin journey


Features

Customer

  • 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

Vendor

  • Application flow with sample images and structured questionnaire
  • Re-apply after rejection (same endpoint flips Status back to Pending)
  • 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

Admin

  • 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)

AI Features

All three are powered by the Anthropic Claude API.

1. Description Generator — POST /api/ai/generate-description

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.

2. Image Enhancement — POST /api/ai/enhance-image

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.

3. AI Chatbot Search — POST /api/ai/search

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.

4. Review Highlights — GET /api/products/{id}/reviews/summary

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.


Demos


Quick Start

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 shell

Stripe webhook forwarding for local development:

docker compose --profile stripe up stripe-cli
# Copy the printed whsec_... into .env as STRIPE_WEBHOOK_SECRET, then restart api

Service 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.


Environment Variables

# 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_).


Project Structure

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 Surface

/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.


Database

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.VendorId and Reviews.VendorId both store User.Id (not VendorProfile.Id). Documented in ProductModule.cs.
  • VendorProfile.ApplicationAnswers is JSONB with camelCase keys matching the VendorApplicationAnswers DTO.
  • S3 paths: vendors/{userId}/{logo|banner}/{guid}{ext}, reviews/{reviewId}/{guid}{ext}. Fresh GUID per upload, so no CDN cache-busting needed.

Order State Machine

Pending  → Confirmed → Shipped → Delivered → Completed
Pending  → Cancelled
Confirmed → Cancelled

Two confirmation paths:

  • Stripe webhook: checkout.session.completed flips Pending → Confirmed and stamps StripePaymentIntentId. Idempotent.
  • Manual vendor transition via PUT /api/orders/{id}/status.

Coding Standards

Backend

  • Result<T> everywhere. Services never throw — they return Result<T>.Success(data) or Result<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.

Frontend

  • Design tokens only. Defined in src/app/globals.css via 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, Skeleton from @/components/ui/*. Use Logo, ProductCard, OrderStatusBadge from @/components/brand/*.
  • Motion primitives. Use FadeIn, Stagger + StaggerItem, HoverLift from @/components/motion/* — never raw framer-motion in pages. They respect prefers-reduced-motion.
  • Icons. lucide-react for UI chrome; @phosphor-icons/react duotone for categories and decorative use. No emojis in UI copy.
  • Loading skeletons, never blank states.
  • Zod on every form, toast feedback on every mutation.

Card / button conventions

  • Cards: rounded-2xl border border-border bg-card p-5
  • Buttons: default rounded-full via <Button>; use buttonVariants() on <Link> (base-ui Button has no asChild)
  • Section eyebrows: text-xs font-medium uppercase tracking-[0.18em] text-taupe

Do not

  • 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

Testing

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 FakeAnthropicClient in 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.UnitTests

Commit rule. Never commit a new service or endpoint without at least a unit test for it.


Deployment

Production runs on Railway with four services (Postgres, Redis, API, web). Configuration uses hierarchical naming (Anthropic__ApiKey, Stripe__SecretKey, ...).


Gotchas

The non-obvious things you will trip over:

  • Products.VendorId / Reviews.VendorId store User.Id, not VendorProfile.Id. On vendor profile pages use useProducts({ vendorId: vendor.userId }) — not the URL id param.
  • 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/me mutations gate on Status == "Approved" at the service level. Endpoint-level RequireAuthorization("VendorOrAdmin") is defence-in-depth, not the primary check.
  • S3 bucket is BucketOwnerEnforced — never set a CannedACL on uploads. Visibility is bucket-policy controlled.
  • Product status is one-hop only. Draft ↔ Active, Active → Sold. Sold is terminal. Don't add transitions by special-casing callers.
  • DatabaseSeeder has two phases. SeedAsync short-circuits when products exist, then calls TopUpDemoDataAsync to 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:WebhookSecret is empty — makes it obvious when env hasn't propagated.
  • HandleCheckoutCompletedAsync is idempotent (only acts on Pending orders), so webhook re-deliveries are safe. Vendors can still manually transition Pending → 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.

License

Private project.

About

Every piece has a story. A multi-vendor thrift & vintage marketplace with AI listing tools, virtual try-on, and chatbot search.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages