commit b37b734c82827fa90245654588759b6df3b97ce6 Author: delta-lynx-89e8 Date: Sun Feb 22 07:00:44 2026 -0800 Initial marketplace implementation Full-stack marketplace for buying/selling second-hand items. React 19 + TypeScript + Tailwind CSS v4 frontend with 17 screens, Express + Prisma + Socket.io backend, Stripe payments, JWT auth. Deployed at https://marketplace.173.212.212.157.sslip.io/ Co-Authored-By: Claude Opus 4.6 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9267147 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "figma": { + "type": "http", + "url": "https://mcp.figma.com/mcp" + }, + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics", "--isolated"] + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d14c47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Logs +logs +*.log +npm-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment +.env +server/.env +server/uploads/ + +# Prisma +server/prisma/*.db + +# OS +Thumbs.db diff --git a/MARKETPLACE/buy_item.png b/MARKETPLACE/buy_item.png new file mode 100644 index 0000000..31b02b4 Binary files /dev/null and b/MARKETPLACE/buy_item.png differ diff --git a/MARKETPLACE/categorymenu.png b/MARKETPLACE/categorymenu.png new file mode 100644 index 0000000..a21068c Binary files /dev/null and b/MARKETPLACE/categorymenu.png differ diff --git a/MARKETPLACE/createuserprofile.png b/MARKETPLACE/createuserprofile.png new file mode 100644 index 0000000..0f38e15 Binary files /dev/null and b/MARKETPLACE/createuserprofile.png differ diff --git a/MARKETPLACE/edit_item_info.png b/MARKETPLACE/edit_item_info.png new file mode 100644 index 0000000..a22fa04 Binary files /dev/null and b/MARKETPLACE/edit_item_info.png differ diff --git a/MARKETPLACE/footer.png b/MARKETPLACE/footer.png new file mode 100644 index 0000000..866a91f Binary files /dev/null and b/MARKETPLACE/footer.png differ diff --git a/MARKETPLACE/hero3333.png b/MARKETPLACE/hero3333.png new file mode 100644 index 0000000..a7afa6c Binary files /dev/null and b/MARKETPLACE/hero3333.png differ diff --git a/MARKETPLACE/log in.png b/MARKETPLACE/log in.png new file mode 100644 index 0000000..eceb93d Binary files /dev/null and b/MARKETPLACE/log in.png differ diff --git a/MARKETPLACE/makeoffer.png b/MARKETPLACE/makeoffer.png new file mode 100644 index 0000000..27dccf7 Binary files /dev/null and b/MARKETPLACE/makeoffer.png differ diff --git a/MARKETPLACE/memberchat.png b/MARKETPLACE/memberchat.png new file mode 100644 index 0000000..7215ff7 Binary files /dev/null and b/MARKETPLACE/memberchat.png differ diff --git a/MARKETPLACE/notifications.png b/MARKETPLACE/notifications.png new file mode 100644 index 0000000..10cf56e Binary files /dev/null and b/MARKETPLACE/notifications.png differ diff --git a/MARKETPLACE/offers.png b/MARKETPLACE/offers.png new file mode 100644 index 0000000..c71fc90 Binary files /dev/null and b/MARKETPLACE/offers.png differ diff --git a/MARKETPLACE/payment.png b/MARKETPLACE/payment.png new file mode 100644 index 0000000..620a21f Binary files /dev/null and b/MARKETPLACE/payment.png differ diff --git a/MARKETPLACE/sell_item.png b/MARKETPLACE/sell_item.png new file mode 100644 index 0000000..14c6005 Binary files /dev/null and b/MARKETPLACE/sell_item.png differ diff --git a/MARKETPLACE/settings.png b/MARKETPLACE/settings.png new file mode 100644 index 0000000..b2c85a2 Binary files /dev/null and b/MARKETPLACE/settings.png differ diff --git a/MARKETPLACE/sign up.png b/MARKETPLACE/sign up.png new file mode 100644 index 0000000..d059669 Binary files /dev/null and b/MARKETPLACE/sign up.png differ diff --git a/MARKETPLACE/sold_items.png b/MARKETPLACE/sold_items.png new file mode 100644 index 0000000..43f77a2 Binary files /dev/null and b/MARKETPLACE/sold_items.png differ diff --git a/MARKETPLACE/updateprofile.png b/MARKETPLACE/updateprofile.png new file mode 100644 index 0000000..372337e Binary files /dev/null and b/MARKETPLACE/updateprofile.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/client/eslint.config.js b/client/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/client/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..5301b55 --- /dev/null +++ b/client/index.html @@ -0,0 +1,16 @@ + + + + + + + Marketplace - Buy & Sell Second-Hand Items + + + + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..a3af71b --- /dev/null +++ b/client/package.json @@ -0,0 +1,37 @@ +{ + "name": "marketplace-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0", + "socket.io-client": "^4.8.0", + "@stripe/stripe-js": "^5.0.0", + "@stripe/react-stripe-js": "^3.1.0", + "lucide-react": "^0.469.0" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "tailwindcss": "^4.0.0", + "typescript": "~5.7.0", + "typescript-eslint": "^8.18.0", + "vite": "^6.0.0" + } +} diff --git a/client/public/vite.svg b/client/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..966fee4 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,15 @@ +import { Outlet } from 'react-router-dom'; +import { Header } from './components/layout/Header'; +import { Footer } from './components/layout/Footer'; + +export function App() { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/client/src/api/client.ts b/client/src/api/client.ts new file mode 100644 index 0000000..c02c843 --- /dev/null +++ b/client/src/api/client.ts @@ -0,0 +1,74 @@ +const API_BASE = '/api'; + +class ApiClient { + private accessToken: string | null = null; + + setToken(token: string | null) { + this.accessToken = token; + } + + private async request(path: string, options: RequestInit = {}): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + ...options.headers as Record, + }; + + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + credentials: 'include', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); + } + + get(path: string) { return this.request(path); } + + post(path: string, body?: unknown) { + return this.request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }); + } + + put(path: string, body?: unknown) { + return this.request(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }); + } + + patch(path: string, body?: unknown) { + return this.request(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined }); + } + + delete(path: string) { + return this.request(path, { method: 'DELETE' }); + } + + async upload(path: string, formData: FormData): Promise { + const headers: Record = {}; + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + + const response = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers, + body: formData, + credentials: 'include', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Upload failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); + } +} + +export const api = new ApiClient(); diff --git a/client/src/components/layout/DashboardLayout.tsx b/client/src/components/layout/DashboardLayout.tsx new file mode 100644 index 0000000..2904135 --- /dev/null +++ b/client/src/components/layout/DashboardLayout.tsx @@ -0,0 +1,36 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import { MessageSquare, Tag, Bell, ShoppingBag, Settings } from 'lucide-react'; + +const navItems = [ + { to: '/dashboard/messages', icon: MessageSquare, label: 'Messages' }, + { to: '/dashboard/offers', icon: Tag, label: 'Offers' }, + { to: '/dashboard/notifications', icon: Bell, label: 'Notifications' }, + { to: '/dashboard/sold', icon: ShoppingBag, label: 'Sold Items' }, + { to: '/dashboard/settings', icon: Settings, label: 'Settings' }, +]; + +export function DashboardLayout() { + return ( +
+
+ {/* Sidebar */} + + + {/* Content */} +
+ +
+
+
+ ); +} diff --git a/client/src/components/layout/Footer.tsx b/client/src/components/layout/Footer.tsx new file mode 100644 index 0000000..f079677 --- /dev/null +++ b/client/src/components/layout/Footer.tsx @@ -0,0 +1,83 @@ +import { Link } from 'react-router-dom'; +import { Facebook, Twitter, Instagram, Youtube, Package } from 'lucide-react'; + +export function Footer() { + return ( +
+
+
+ {/* Brand */} +
+
+
+ +
+ MARKETPLACE +
+

+ Discover great deals on unique pre-loved items. Buy and sell second-hand items online. +

+
+

Stay Updated

+

Subscribe to our newsletter for tips and special offers

+
+ + +
+
+
+ + {/* Marketplace */} +
+

Marketplace

+
    +
  • Home
  • +
  • Browse
  • +
  • Sell Your Item
  • +
  • My Listings
  • +
+
+ + {/* Support */} + + + {/* About */} +
+

About

+ +
+

Follow Us

+
+ + + + +
+
+
+
+ +
+

© 2024 Marketplace. All Rights Reserved.

+ +
+
+
+ ); +} diff --git a/client/src/components/layout/Header.tsx b/client/src/components/layout/Header.tsx new file mode 100644 index 0000000..d0bbb2f --- /dev/null +++ b/client/src/components/layout/Header.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { Search, Bell, Menu, X, User, LogOut, Package, Heart, MessageSquare, Settings } from 'lucide-react'; +import { useAuth } from '../../context/AuthContext'; +import { Avatar } from '../ui/Avatar'; + +export function Header() { + const { user, isAuthenticated, logout } = useAuth(); + const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(''); + const [showMobileMenu, setShowMobileMenu] = useState(false); + const [showUserMenu, setShowUserMenu] = useState(false); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchQuery.trim()) { + navigate(`/?search=${encodeURIComponent(searchQuery.trim())}`); + } + }; + + return ( +
+
+
+ {/* Logo */} + +
+ +
+ + MARKETPLACE + + + + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 rounded-xl border border-gray-200 bg-gray-50 text-sm + focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none transition-all" + /> +
+
+ + {/* Right side */} +
+ {isAuthenticated ? ( + <> + + Sell Item + + + + + + + + +
+ + {showUserMenu && ( + <> +
setShowUserMenu(false)} /> +
+
+

{user?.fullName}

+

{user?.email}

+
+ setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"> + Profile + + setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"> + My Offers + + setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"> + Sold Items + + setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"> + Settings + +
+ +
+ + )} +
+ + ) : ( + <> + + Log In + + + Sign Up + + + )} + +
+
+ + {/* Mobile menu */} + {showMobileMenu && ( +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 rounded-xl border border-gray-200 bg-gray-50 text-sm focus:border-primary-400 focus:outline-none" /> +
+
+ {isAuthenticated && ( + setShowMobileMenu(false)} + className="block w-full text-center px-4 py-2 text-sm font-semibold text-white rounded-xl bg-gradient-to-r from-pink-500 to-primary-600 mb-2"> + Sell Item + + )} +
+ )} +
+
+ ); +} diff --git a/client/src/components/layout/RequireAuth.tsx b/client/src/components/layout/RequireAuth.tsx new file mode 100644 index 0000000..048fa1f --- /dev/null +++ b/client/src/components/layout/RequireAuth.tsx @@ -0,0 +1,21 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../../context/AuthContext'; + +export function RequireAuth({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/client/src/components/layout/index.ts b/client/src/components/layout/index.ts new file mode 100644 index 0000000..4b988e4 --- /dev/null +++ b/client/src/components/layout/index.ts @@ -0,0 +1,4 @@ +export { Header } from './Header'; +export { Footer } from './Footer'; +export { DashboardLayout } from './DashboardLayout'; +export { RequireAuth } from './RequireAuth'; diff --git a/client/src/components/listings/CategorySidebar.tsx b/client/src/components/listings/CategorySidebar.tsx new file mode 100644 index 0000000..8bd7a30 --- /dev/null +++ b/client/src/components/listings/CategorySidebar.tsx @@ -0,0 +1,45 @@ +import { Smartphone, Armchair, Shirt, Home, Dumbbell, BookOpen, Gamepad2, Car, Package } from 'lucide-react'; + +const categories = [ + { value: 'ELECTRONICS', label: 'Electronics', icon: Smartphone }, + { value: 'FURNITURE', label: 'Furniture', icon: Armchair }, + { value: 'CLOTHING', label: 'Clothing & Shoes', icon: Shirt }, + { value: 'HOME_GARDEN', label: 'Home & Garden', icon: Home }, + { value: 'SPORTS', label: 'Sports & Outdoors', icon: Dumbbell }, + { value: 'BOOKS', label: 'Books', icon: BookOpen }, + { value: 'GAMES', label: 'Games', icon: Gamepad2 }, + { value: 'VEHICLES', label: 'Vehicles', icon: Car }, + { value: 'OTHER', label: 'Other', icon: Package }, +]; + +interface CategorySidebarProps { + selected?: string; + onSelect: (category: string | undefined) => void; +} + +export function CategorySidebar({ selected, onSelect }: CategorySidebarProps) { + return ( + + ); +} diff --git a/client/src/components/listings/ListingCard.tsx b/client/src/components/listings/ListingCard.tsx new file mode 100644 index 0000000..6263780 --- /dev/null +++ b/client/src/components/listings/ListingCard.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Heart, MapPin } from 'lucide-react'; +import { Badge } from '../ui/Badge'; +import type { Listing } from '../../types'; +import { formatCurrency } from '../../utils/format'; + +interface ListingCardProps { + listing: Listing; +} + +export function ListingCard({ listing }: ListingCardProps) { + const [isFav, setIsFav] = useState(listing.isFavorited ?? false); + + const conditionVariant = listing.condition === 'NEW' ? 'success' + : listing.condition === 'LIKE_NEW' ? 'info' + : 'default'; + + return ( + +
+ {/* Image */} +
+
+ + {listing.category === 'FURNITURE' ? '\uD83E\uDE91' : + listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : + listing.category === 'CLOTHING' ? '\uD83D\uDC55' : + listing.category === 'HOME_GARDEN' ? '\u2615' : + listing.category === 'SPORTS' ? '\uD83D\uDEB4' : + listing.category === 'BOOKS' ? '\uD83D\uDCDA' : + listing.category === 'GAMES' ? '\uD83C\uDFAE' : '\uD83D\uDCE6'} + +
+ +
+ + {/* Content */} +
+

+ {listing.title} +

+

+ {formatCurrency(listing.price)} + {listing.obo && OBO} +

+
+ {listing.condition.replace('_', ' ')} + + + {listing.location} + +
+
+
+ + ); +} diff --git a/client/src/components/listings/ListingGrid.tsx b/client/src/components/listings/ListingGrid.tsx new file mode 100644 index 0000000..9b4c482 --- /dev/null +++ b/client/src/components/listings/ListingGrid.tsx @@ -0,0 +1,30 @@ +import { ListingCard } from './ListingCard'; +import type { Listing } from '../../types'; + +interface ListingGridProps { + listings: Listing[]; + title?: string; + showViewAll?: boolean; +} + +export function ListingGrid({ listings, title, showViewAll }: ListingGridProps) { + return ( +
+ {title && ( +
+

{title}

+ {showViewAll && ( + + )} +
+ )} +
+ {listings.map(listing => ( + + ))} +
+
+ ); +} diff --git a/client/src/components/listings/index.ts b/client/src/components/listings/index.ts new file mode 100644 index 0000000..68e1c14 --- /dev/null +++ b/client/src/components/listings/index.ts @@ -0,0 +1,3 @@ +export { ListingCard } from './ListingCard'; +export { ListingGrid } from './ListingGrid'; +export { CategorySidebar } from './CategorySidebar'; diff --git a/client/src/components/ui/Avatar.tsx b/client/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..5366df0 --- /dev/null +++ b/client/src/components/ui/Avatar.tsx @@ -0,0 +1,26 @@ +interface AvatarProps { + src?: string; + name: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +export function Avatar({ src, name, size = 'md' }: AvatarProps) { + const sizes = { + sm: 'w-8 h-8 text-xs', + md: 'w-10 h-10 text-sm', + lg: 'w-14 h-14 text-lg', + xl: 'w-20 h-20 text-2xl', + }; + + const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2); + + if (src) { + return {name}; + } + + return ( +
+ {initials} +
+ ); +} diff --git a/client/src/components/ui/Badge.tsx b/client/src/components/ui/Badge.tsx new file mode 100644 index 0000000..a99ead2 --- /dev/null +++ b/client/src/components/ui/Badge.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; + +interface BadgeProps { + children: ReactNode; + variant?: 'default' | 'success' | 'warning' | 'error' | 'info'; + size?: 'sm' | 'md'; +} + +export function Badge({ children, variant = 'default', size = 'sm' }: BadgeProps) { + const variants = { + default: 'bg-gray-100 text-gray-600', + success: 'bg-green-100 text-green-700', + warning: 'bg-amber-100 text-amber-700', + error: 'bg-red-100 text-red-700', + info: 'bg-primary-100 text-primary-700', + }; + + const sizes = { + sm: 'px-2 py-0.5 text-xs', + md: 'px-3 py-1 text-sm', + }; + + return ( + + {children} + + ); +} diff --git a/client/src/components/ui/Button.tsx b/client/src/components/ui/Button.tsx new file mode 100644 index 0000000..cd9e68a --- /dev/null +++ b/client/src/components/ui/Button.tsx @@ -0,0 +1,42 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + children: ReactNode; + isLoading?: boolean; +} + +export function Button({ variant = 'primary', size = 'md', children, isLoading, className = '', disabled, ...props }: ButtonProps) { + const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed'; + + const variants = { + primary: 'bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800', + secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200', + outline: 'border-2 border-primary-300 text-primary-600 hover:bg-primary-50', + ghost: 'text-gray-600 hover:bg-gray-100', + danger: 'bg-red-500 text-white hover:bg-red-600', + }; + + const sizes = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-5 py-2.5 text-sm', + lg: 'px-6 py-3 text-base', + }; + + return ( + + ); +} diff --git a/client/src/components/ui/Card.tsx b/client/src/components/ui/Card.tsx new file mode 100644 index 0000000..e7105dd --- /dev/null +++ b/client/src/components/ui/Card.tsx @@ -0,0 +1,26 @@ +import type { HTMLAttributes, ReactNode } from 'react'; + +interface CardProps extends HTMLAttributes { + children: ReactNode; + padding?: 'none' | 'sm' | 'md' | 'lg'; + hover?: boolean; +} + +export function Card({ children, padding = 'md', hover = false, className = '', ...props }: CardProps) { + const paddings = { + none: '', + sm: 'p-3', + md: 'p-5', + lg: 'p-6', + }; + + return ( +
+ {children} +
+ ); +} diff --git a/client/src/components/ui/GradientButton.tsx b/client/src/components/ui/GradientButton.tsx new file mode 100644 index 0000000..77e4b7b --- /dev/null +++ b/client/src/components/ui/GradientButton.tsx @@ -0,0 +1,34 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface GradientButtonProps extends ButtonHTMLAttributes { + size?: 'sm' | 'md' | 'lg'; + children: ReactNode; + isLoading?: boolean; +} + +export function GradientButton({ size = 'md', children, isLoading, className = '', disabled, ...props }: GradientButtonProps) { + const sizes = { + sm: 'px-4 py-2 text-sm', + md: 'px-6 py-3 text-sm', + lg: 'px-8 py-3.5 text-base', + }; + + return ( + + ); +} diff --git a/client/src/components/ui/Input.tsx b/client/src/components/ui/Input.tsx new file mode 100644 index 0000000..ae84b9f --- /dev/null +++ b/client/src/components/ui/Input.tsx @@ -0,0 +1,30 @@ +import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + icon?: ReactNode; +} + +export const Input = forwardRef( + ({ label, error, icon, className = '', ...props }, ref) => { + return ( +
+ {label && } +
+ {icon &&
{icon}
} + +
+ {error &&

{error}

} +
+ ); + } +); + +Input.displayName = 'Input'; diff --git a/client/src/components/ui/Modal.tsx b/client/src/components/ui/Modal.tsx new file mode 100644 index 0000000..a2edcec --- /dev/null +++ b/client/src/components/ui/Modal.tsx @@ -0,0 +1,46 @@ +import { useEffect, type ReactNode } from 'react'; +import { X } from 'lucide-react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: ReactNode; + size?: 'sm' | 'md' | 'lg'; +} + +export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) { + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { document.body.style.overflow = ''; }; + }, [isOpen]); + + if (!isOpen) return null; + + const sizes = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + }; + + return ( +
+
+
+ {title && ( +
+

{title}

+ +
+ )} + {children} +
+
+ ); +} diff --git a/client/src/components/ui/Toggle.tsx b/client/src/components/ui/Toggle.tsx new file mode 100644 index 0000000..f4ea524 --- /dev/null +++ b/client/src/components/ui/Toggle.tsx @@ -0,0 +1,31 @@ +interface ToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + label?: string; + description?: string; +} + +export function Toggle({ checked, onChange, label, description }: ToggleProps) { + return ( + + ); +} diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts new file mode 100644 index 0000000..0d8cc29 --- /dev/null +++ b/client/src/components/ui/index.ts @@ -0,0 +1,8 @@ +export { Button } from './Button'; +export { GradientButton } from './GradientButton'; +export { Input } from './Input'; +export { Modal } from './Modal'; +export { Card } from './Card'; +export { Avatar } from './Avatar'; +export { Toggle } from './Toggle'; +export { Badge } from './Badge'; diff --git a/client/src/context/AuthContext.tsx b/client/src/context/AuthContext.tsx new file mode 100644 index 0000000..9ae8242 --- /dev/null +++ b/client/src/context/AuthContext.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'; +import type { User } from '../types'; +import { api } from '../api/client'; + +interface AuthContextType { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (email: string, password: string) => Promise; + signup: (data: { fullName: string; email: string; password: string }) => Promise; + logout: () => void; + updateUser: (data: Partial) => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem('accessToken'); + if (token) { + api.setToken(token); + api.get<{ user: User }>('/auth/me') + .then(({ user }) => setUser(user)) + .catch(() => { + localStorage.removeItem('accessToken'); + api.setToken(null); + }) + .finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + } + }, []); + + const login = useCallback(async (email: string, password: string) => { + const { user, accessToken } = await api.post<{ user: User; accessToken: string }>('/auth/login', { email, password }); + localStorage.setItem('accessToken', accessToken); + api.setToken(accessToken); + setUser(user); + }, []); + + const signup = useCallback(async (data: { fullName: string; email: string; password: string }) => { + const { user, accessToken } = await api.post<{ user: User; accessToken: string }>('/auth/register', data); + localStorage.setItem('accessToken', accessToken); + api.setToken(accessToken); + setUser(user); + }, []); + + const logout = useCallback(() => { + localStorage.removeItem('accessToken'); + api.setToken(null); + setUser(null); + }, []); + + const updateUser = useCallback((data: Partial) => { + setUser(prev => prev ? { ...prev, ...data } : null); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used within AuthProvider'); + return context; +} diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..26d62de --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,55 @@ +@import "tailwindcss"; + +@theme { + --font-sans: 'Inter', sans-serif; + + --color-primary-50: #f5f3ff; + --color-primary-100: #ede9fe; + --color-primary-200: #ddd6fe; + --color-primary-300: #c4b5fd; + --color-primary-400: #a78bfa; + --color-primary-500: #8b5cf6; + --color-primary-600: #7c3aed; + --color-primary-700: #6d28d9; + --color-primary-800: #5b21b6; + --color-primary-900: #4c1d95; + + --color-pink-400: #f472b6; + --color-pink-500: #ec4899; + --color-pink-600: #db2777; + + --color-accent: #E994B8; + + --color-surface: #ffffff; + --color-background: #f3f0ff; + --color-text: #1f2937; + --color-text-secondary: #6b7280; + --color-border: #e5e7eb; + --color-success: #10b981; + --color-error: #ef4444; + --color-warning: #f59e0b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--color-background); + color: var(--color-text); + font-family: 'Inter', sans-serif; + min-height: 100vh; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..3e15feb --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { RouterProvider } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext'; +import { router } from './router'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/client/src/pages/ChatPage.tsx b/client/src/pages/ChatPage.tsx new file mode 100644 index 0000000..299d963 --- /dev/null +++ b/client/src/pages/ChatPage.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Send } from 'lucide-react'; +import { Avatar } from '../components/ui/Avatar'; +import { mockConversations, mockMessages, mockCurrentUser } from '../utils/mockData'; +import { formatDate, formatCurrency } from '../utils/format'; + +export function ChatPage() { + const [selectedConv, setSelectedConv] = useState(mockConversations[0]?.id); + const [newMessage, setNewMessage] = useState(''); + const messages = mockMessages.filter(m => m.conversationId === selectedConv); + const activeConv = mockConversations.find(c => c.id === selectedConv); + const otherUser = activeConv + ? (activeConv.user1.id === mockCurrentUser.id ? activeConv.user2 : activeConv.user1) + : null; + + const handleSend = (e: React.FormEvent) => { + e.preventDefault(); + if (!newMessage.trim()) return; + setNewMessage(''); + }; + + return ( +
+
+ {/* Conversation List */} +
+
+

Messages

+
+
+ {mockConversations.map(conv => { + const other = conv.user1.id === mockCurrentUser.id ? conv.user2 : conv.user1; + return ( + + ); + })} +
+
+ + {/* Chat Area */} +
+ {activeConv ? ( + <> + {/* Chat Header */} +
+ +
+

{otherUser?.nickname || otherUser?.fullName}

+ {activeConv.listing && ( +

{activeConv.listing.title} · {formatCurrency(activeConv.listing.price)}

+ )} +
+
+ + {/* Messages */} +
+ {messages.map(msg => { + const isMe = msg.senderId === mockCurrentUser.id; + return ( +
+
+

{msg.content}

+

{formatDate(msg.createdAt)}

+
+
+ ); + })} +
+ + {/* Input */} +
+ setNewMessage(e.target.value)} + placeholder="Type a message..." + className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 text-sm focus:border-primary-400 focus:outline-none" /> + +
+ + ) : ( +
+ Select a conversation to start chatting +
+ )} +
+
+
+ ); +} diff --git a/client/src/pages/CreateProfilePage.tsx b/client/src/pages/CreateProfilePage.tsx new file mode 100644 index 0000000..0a6afe6 --- /dev/null +++ b/client/src/pages/CreateProfilePage.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { User, Mail, Phone, MapPin, Camera } from 'lucide-react'; +import { Input } from '../components/ui/Input'; +import { GradientButton } from '../components/ui/GradientButton'; +import { Card } from '../components/ui/Card'; +import { Avatar } from '../components/ui/Avatar'; + +export function CreateProfilePage() { + const navigate = useNavigate(); + const [fullName, setFullName] = useState(''); + const [email, setEmail] = useState(''); + const [phone, setPhone] = useState(''); + const [location, setLocation] = useState(''); + const [bio, setBio] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + navigate('/'); + }; + + return ( +
+ +
+

Create User Profile

+

Set up your profile to start buying and selling on the marketplace

+
+ +
+
+ + +
+
+ +
+ setFullName(e.target.value)} + icon={} required /> + setEmail(e.target.value)} + icon={} required /> + setPhone(e.target.value)} + icon={} /> + setLocation(e.target.value)} + icon={} /> +
+ +