Add B2B marketplace frontend with virtualized catalog
This commit is contained in:
parent
b0507e29a4
commit
424fbdba1d
24 changed files with 3985 additions and 0 deletions
5
marketplace-front/.gitignore
vendored
Normal file
5
marketplace-front/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
.dist
|
||||||
|
dist
|
||||||
|
.env*
|
||||||
|
*.tsbuildinfo
|
||||||
13
marketplace-front/index.html
Normal file
13
marketplace-front/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Marketplace Farmacêutico B2B</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3019
marketplace-front/package-lock.json
generated
Normal file
3019
marketplace-front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
marketplace-front/package.json
Normal file
31
marketplace-front/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"name": "marketplace-front",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mercadopago/sdk-react": "^1.0.6",
|
||||||
|
"axios": "^1.7.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0",
|
||||||
|
"react-window": "^1.8.10",
|
||||||
|
"zustand": "^4.5.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.7",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.10",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
marketplace-front/postcss.config.cjs
Normal file
6
marketplace-front/postcss.config.cjs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
50
marketplace-front/src/App.tsx
Normal file
50
marketplace-front/src/App.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { LoginPage } from './pages/Login'
|
||||||
|
import { DashboardPage } from './pages/Dashboard'
|
||||||
|
import { CartPage } from './pages/Cart'
|
||||||
|
import { CheckoutPage } from './pages/Checkout'
|
||||||
|
import { ProfilePage } from './pages/Profile'
|
||||||
|
import { ProtectedRoute } from './components/ProtectedRoute'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cart"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CartPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/checkout"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<CheckoutPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['admin']}>
|
||||||
|
<ProfilePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
26
marketplace-front/src/components/ProtectedRoute.tsx
Normal file
26
marketplace-front/src/components/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { useAuth, UserRole } from '../context/AuthContext'
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: ReactNode
|
||||||
|
allowedRoles?: UserRole[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children, allowedRoles }: ProtectedRouteProps) {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-6 text-center">Carregando sessão...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedRoles && !allowedRoles.includes(user.role)) {
|
||||||
|
return <Navigate to="/dashboard" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
73
marketplace-front/src/context/AuthContext.tsx
Normal file
73
marketplace-front/src/context/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { apiClient } from '../services/apiClient'
|
||||||
|
|
||||||
|
export type UserRole = 'farmacia' | 'admin'
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
name: string
|
||||||
|
role: UserRole
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: AuthUser | null
|
||||||
|
loading: boolean
|
||||||
|
login: (token: string, role: UserRole, name: string) => void
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
const AUTH_KEY = 'mp-auth-user'
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||||
|
const persisted = localStorage.getItem(AUTH_KEY)
|
||||||
|
return persisted ? (JSON.parse(persisted) as AuthUser) : null
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem(AUTH_KEY, JSON.stringify(user))
|
||||||
|
apiClient.setToken(user.token)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(AUTH_KEY)
|
||||||
|
apiClient.setToken(null)
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const login = (token: string, role: UserRole, name: string) => {
|
||||||
|
setUser({ token, role, name })
|
||||||
|
navigate('/dashboard', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null)
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
}),
|
||||||
|
[user, loading]
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
28
marketplace-front/src/hooks/usePersistentFilters.ts
Normal file
28
marketplace-front/src/hooks/usePersistentFilters.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
activeIngredient: string
|
||||||
|
category: string
|
||||||
|
lab: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mp-quick-filters'
|
||||||
|
|
||||||
|
export const defaultFilters: FilterState = {
|
||||||
|
activeIngredient: '',
|
||||||
|
category: '',
|
||||||
|
lab: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePersistentFilters() {
|
||||||
|
const [filters, setFilters] = useState<FilterState>(() => {
|
||||||
|
const persisted = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return persisted ? (JSON.parse(persisted) as FilterState) : defaultFilters
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
|
return { filters, setFilters }
|
||||||
|
}
|
||||||
28
marketplace-front/src/index.css
Normal file
28
marketplace-front/src/index.css
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color: #0f172a;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
background-color: #0F4C81;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
background-color: #2D9CDB;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-surface {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
44
marketplace-front/src/layouts/Shell.tsx
Normal file
44
marketplace-front/src/layouts/Shell.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<header className="flex items-center justify-between bg-medicalBlue px-6 py-4 text-white shadow-md">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-white/10 text-center text-xl font-bold leading-10">MP</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold">Marketplace Farmacêutico B2B</p>
|
||||||
|
<p className="text-sm text-gray-100">Dashboard de Compras</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center gap-4 text-sm font-medium">
|
||||||
|
<Link to="/dashboard" className="hover:underline">
|
||||||
|
Compras
|
||||||
|
</Link>
|
||||||
|
<Link to="/cart" className="hover:underline">
|
||||||
|
Carrinho
|
||||||
|
</Link>
|
||||||
|
<Link to="/checkout" className="hover:underline">
|
||||||
|
Checkout
|
||||||
|
</Link>
|
||||||
|
<Link to="/profile" className="hover:underline">
|
||||||
|
Perfil da Empresa
|
||||||
|
</Link>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="rounded bg-white/10 px-3 py-1 text-xs font-semibold hover:bg-white/20"
|
||||||
|
>
|
||||||
|
Sair ({user.role})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main className="px-6 py-5">{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
marketplace-front/src/main.tsx
Normal file
16
marketplace-front/src/main.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
111
marketplace-front/src/pages/Cart.tsx
Normal file
111
marketplace-front/src/pages/Cart.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { Shell } from '../layouts/Shell'
|
||||||
|
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
|
||||||
|
|
||||||
|
export function CartPage() {
|
||||||
|
const items = useCartStore((state) => state.items)
|
||||||
|
const groups = useCartStore(selectGroupedCart)
|
||||||
|
const summary = useCartStore(selectCartSummary)
|
||||||
|
const updateQuantity = useCartStore((state) => state.updateQuantity)
|
||||||
|
const removeItem = useCartStore((state) => state.removeItem)
|
||||||
|
const clearVendor = useCartStore((state) => state.clearVendor)
|
||||||
|
const clearAll = useCartStore((state) => state.clearAll)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-medicalBlue">Carrinho B2B</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Agrupamento automático por distribuidora para suportar checkout unificado ou múltiplo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-600">Itens: {summary.totalItems}</p>
|
||||||
|
<p className="text-lg font-semibold text-medicalBlue">R$ {summary.totalValue.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="rounded-lg bg-white p-6 text-center text-gray-600 shadow">Nenhum item no carrinho.</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(groups).map(([vendorId, group]) => (
|
||||||
|
<div key={vendorId} className="space-y-2 rounded-lg bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 pb-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold text-gray-800">{group.vendorName}</p>
|
||||||
|
<p className="text-xs text-gray-500">Checkout dedicado ou combine no fluxo unificado.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="text-xs font-semibold text-medicalBlue hover:underline"
|
||||||
|
onClick={() => clearVendor(vendorId)}
|
||||||
|
>
|
||||||
|
Limpar fornecedor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="grid grid-cols-6 items-center gap-3 rounded bg-gray-50 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="font-semibold text-gray-800">{item.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{item.activeIngredient} • Lote {item.batch}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Validade</p>
|
||||||
|
<p className="font-medium text-gray-800">{item.expiry}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500">Preço</p>
|
||||||
|
<p className="font-semibold text-medicalBlue">R$ {item.unitPrice.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="w-20 rounded border border-gray-200 px-2 py-1"
|
||||||
|
value={item.quantity}
|
||||||
|
onChange={(e) => updateQuantity(item.id, Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">unidades</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500">Subtotal</p>
|
||||||
|
<p className="font-semibold text-gray-800">
|
||||||
|
R$ {(item.quantity * item.unitPrice).toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="text-xs text-red-500 hover:underline"
|
||||||
|
onClick={() => removeItem(item.id)}
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-between rounded bg-gray-100 px-3 py-2 text-sm">
|
||||||
|
<span className="font-semibold text-gray-700">Total do fornecedor</span>
|
||||||
|
<span className="font-bold text-medicalBlue">R$ {group.total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button className="rounded bg-gray-200 px-4 py-2 text-sm font-semibold" onClick={clearAll}>
|
||||||
|
Limpar carrinho
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/checkout"
|
||||||
|
className="rounded bg-healthGreen px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Seguir para checkout unificado
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
marketplace-front/src/pages/Checkout.tsx
Normal file
73
marketplace-front/src/pages/Checkout.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { initMercadoPago, Payment } from '@mercadopago/sdk-react'
|
||||||
|
import { Shell } from '../layouts/Shell'
|
||||||
|
import { selectGroupedCart, selectCartSummary, useCartStore } from '../stores/cartStore'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
const MP_PUBLIC_KEY = import.meta.env.VITE_MP_PUBLIC_KEY || 'TEST-PUBLIC-KEY'
|
||||||
|
|
||||||
|
export function CheckoutPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const summary = useCartStore(selectCartSummary)
|
||||||
|
const groups = useCartStore(selectGroupedCart)
|
||||||
|
const [status, setStatus] = useState<'pendente' | 'pago' | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initMercadoPago(MP_PUBLIC_KEY, { locale: 'pt-BR' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const preferenceId = useMemo(() => `pref-${Date.now()}`, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="lg:col-span-2 space-y-3 rounded-lg bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-medicalBlue">Checkout e Pagamento</h1>
|
||||||
|
<p className="text-sm text-gray-600">Split automático por distribuidora, status pendente/pago.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500">Comprador</p>
|
||||||
|
<p className="font-semibold text-gray-800">{user?.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Object.entries(groups).map(([vendorId, group]) => (
|
||||||
|
<div key={vendorId} className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-semibold text-gray-800">{group.vendorName}</p>
|
||||||
|
<p className="text-sm font-bold text-medicalBlue">R$ {group.total.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Itens enviados no split de pagamento.</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 rounded-lg bg-white p-4 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-700">Resumo</p>
|
||||||
|
<p className="text-2xl font-bold text-medicalBlue">R$ {summary.totalValue.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-gray-100 p-3 text-sm">
|
||||||
|
<p className="font-semibold text-gray-700">Status</p>
|
||||||
|
<p className="text-gray-600">{status ? status.toUpperCase() : 'Aguardando pagamento'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-gray-50 p-3">
|
||||||
|
<p className="text-sm font-semibold text-gray-700">Pagamento Mercado Pago</p>
|
||||||
|
<Payment
|
||||||
|
initialization={{ preferenceId, amount: summary.totalValue || 0.01 }}
|
||||||
|
customization={{ paymentMethods: { creditCard: 'all', bankTransfer: 'all' } }}
|
||||||
|
onSubmit={async () => {
|
||||||
|
setStatus('pendente')
|
||||||
|
}}
|
||||||
|
onReady={() => console.log('Payment brick pronto')}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error(error)
|
||||||
|
setStatus(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
176
marketplace-front/src/pages/Dashboard.tsx
Normal file
176
marketplace-front/src/pages/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { FixedSizeList as List, ListChildComponentProps } from 'react-window'
|
||||||
|
import { usePersistentFilters, defaultFilters } from '../hooks/usePersistentFilters'
|
||||||
|
import { Product } from '../types/product'
|
||||||
|
import { useCartStore } from '../stores/cartStore'
|
||||||
|
import { Shell } from '../layouts/Shell'
|
||||||
|
|
||||||
|
const mockProducts: Product[] = Array.from({ length: 500 }).map((_, idx) => {
|
||||||
|
const lab = ['EMS', 'Eurofarma', 'Aché'][idx % 3]
|
||||||
|
const actives = ['Amoxicilina', 'Dipirona', 'Losartana']
|
||||||
|
const categories = ['Antibiótico', 'Analgésico', 'Cardiovascular']
|
||||||
|
return {
|
||||||
|
id: `prod-${idx + 1}`,
|
||||||
|
name: `Produto ${idx + 1}`,
|
||||||
|
activeIngredient: actives[idx % actives.length],
|
||||||
|
lab,
|
||||||
|
category: categories[idx % categories.length],
|
||||||
|
batch: `L${1000 + idx}`,
|
||||||
|
expiry: `202${(idx % 4) + 5}-0${(idx % 9) + 1}`,
|
||||||
|
vendorId: `vendor-${idx % 4}`,
|
||||||
|
vendorName: ['Distribuidora Norte', 'Distribuidora Sul', 'FastMed', 'HealthPro'][idx % 4],
|
||||||
|
price: 10 + (idx % 20)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function DenseRow({ index, style, data }: ListChildComponentProps<Product[]>) {
|
||||||
|
const product = data[index]
|
||||||
|
const addItem = useCartStore((state) => state.addItem)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className="grid grid-cols-9 items-center gap-2 border-b border-gray-200 bg-gray-50 px-3 text-sm hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<div className="truncate font-semibold text-gray-800">{product.name}</div>
|
||||||
|
<div className="truncate text-gray-700">{product.activeIngredient}</div>
|
||||||
|
<div className="truncate text-gray-700">{product.lab}</div>
|
||||||
|
<div className="truncate text-gray-700">{product.batch}</div>
|
||||||
|
<div className="truncate text-gray-700">{product.expiry}</div>
|
||||||
|
<div className="truncate text-gray-700">{product.vendorName}</div>
|
||||||
|
<div className="text-right font-semibold text-medicalBlue">R$ {product.price.toFixed(2)}</div>
|
||||||
|
<div className="col-span-2 flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded bg-healthGreen px-3 py-1 text-xs font-semibold text-white shadow-sm hover:opacity-90"
|
||||||
|
onClick={() => addItem({
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
activeIngredient: product.activeIngredient,
|
||||||
|
lab: product.lab,
|
||||||
|
batch: product.batch,
|
||||||
|
expiry: product.expiry,
|
||||||
|
vendorId: product.vendorId,
|
||||||
|
vendorName: product.vendorName,
|
||||||
|
unitPrice: product.price
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { filters, setFilters } = usePersistentFilters()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return mockProducts.filter((p) => {
|
||||||
|
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
const matchesActive = filters.activeIngredient
|
||||||
|
? p.activeIngredient === filters.activeIngredient
|
||||||
|
: true
|
||||||
|
const matchesCategory = filters.category ? p.category === filters.category : true
|
||||||
|
const matchesLab = filters.lab ? p.lab === filters.lab : true
|
||||||
|
return matchesSearch && matchesActive && matchesCategory && matchesLab
|
||||||
|
})
|
||||||
|
}, [filters.activeIngredient, filters.category, filters.lab, search])
|
||||||
|
|
||||||
|
const labs = Array.from(new Set(mockProducts.map((p) => p.lab)))
|
||||||
|
const actives = Array.from(new Set(mockProducts.map((p) => p.activeIngredient)))
|
||||||
|
const categories = Array.from(new Set(mockProducts.map((p) => p.category)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="mb-4 flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 rounded-lg bg-white p-4 shadow-sm lg:grid-cols-4">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<p className="text-sm font-semibold text-gray-700">Busca rápida</p>
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Procure por nome ou SKU"
|
||||||
|
className="mt-2 w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Otimizada para milhares de SKUs com renderização virtualizada.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-700">Princípio Ativo</label>
|
||||||
|
<select
|
||||||
|
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
|
||||||
|
value={filters.activeIngredient}
|
||||||
|
onChange={(e) => setFilters((prev) => ({ ...prev, activeIngredient: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{actives.map((active) => (
|
||||||
|
<option key={active} value={active}>
|
||||||
|
{active}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-700">Categoria</label>
|
||||||
|
<select
|
||||||
|
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
|
||||||
|
value={filters.category}
|
||||||
|
onChange={(e) => setFilters((prev) => ({ ...prev, category: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Todas</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-semibold text-gray-700">Laboratório</label>
|
||||||
|
<select
|
||||||
|
className="mt-2 w-full rounded border border-gray-200 px-2 py-2"
|
||||||
|
value={filters.lab}
|
||||||
|
onChange={(e) => setFilters((prev) => ({ ...prev, lab: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{labs.map((lab) => (
|
||||||
|
<option key={lab} value={lab}>
|
||||||
|
{lab}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
className="rounded bg-gray-200 px-3 py-2 text-xs font-semibold text-gray-700"
|
||||||
|
onClick={() => setFilters(defaultFilters)}
|
||||||
|
>
|
||||||
|
Limpar filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-200 shadow-inner">
|
||||||
|
<div className="table-header grid grid-cols-9 gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wide">
|
||||||
|
<span>Nome</span>
|
||||||
|
<span>Princípio Ativo</span>
|
||||||
|
<span>Laboratório</span>
|
||||||
|
<span>Lote</span>
|
||||||
|
<span>Validade</span>
|
||||||
|
<span>Distribuidora</span>
|
||||||
|
<span className="text-right">Preço</span>
|
||||||
|
<span className="col-span-2 text-right">Ações</span>
|
||||||
|
</div>
|
||||||
|
<List
|
||||||
|
height={520}
|
||||||
|
itemCount={filtered.length}
|
||||||
|
itemSize={52}
|
||||||
|
width="100%"
|
||||||
|
itemData={filtered}
|
||||||
|
>
|
||||||
|
{DenseRow}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
marketplace-front/src/pages/Login.tsx
Normal file
53
marketplace-front/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { useAuth, UserRole } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [role, setRole] = useState<UserRole>('farmacia')
|
||||||
|
const [name, setName] = useState('Dra. Silva')
|
||||||
|
|
||||||
|
const onSubmit = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
login('fake-jwt-token', role, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-100">
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="w-full max-w-md space-y-4 rounded-lg bg-white p-8 shadow-lg"
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-bold text-medicalBlue">Acesso ao Marketplace</h1>
|
||||||
|
<p className="text-sm text-gray-600">Use o nível de acesso para testar as rotas protegidas.</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700" htmlFor="name">
|
||||||
|
Nome do usuário
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700" htmlFor="role">
|
||||||
|
Perfil
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
className="w-full rounded border border-gray-200 px-3 py-2"
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value as UserRole)}
|
||||||
|
>
|
||||||
|
<option value="farmacia">Farmácia</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="w-full rounded bg-healthGreen px-4 py-2 font-semibold text-white">
|
||||||
|
Entrar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
marketplace-front/src/pages/Profile.tsx
Normal file
57
marketplace-front/src/pages/Profile.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Shell } from '../layouts/Shell'
|
||||||
|
|
||||||
|
interface DocumentUpload {
|
||||||
|
name: string
|
||||||
|
status: 'validado' | 'pendente'
|
||||||
|
lastUpdate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const [docs, setDocs] = useState<DocumentUpload[]>([
|
||||||
|
{ name: 'Alvará Sanitário', status: 'validado', lastUpdate: '2024-06-10' },
|
||||||
|
{ name: 'CRF Responsável Técnico', status: 'pendente', lastUpdate: '2024-05-02' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const updateStatus = (name: string, status: DocumentUpload['status']) => {
|
||||||
|
setDocs((prev) => prev.map((doc) => (doc.name === name ? { ...doc, status } : doc)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="space-y-4 rounded-lg bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-medicalBlue">Perfil da Empresa</h1>
|
||||||
|
<p className="text-sm text-gray-600">Envie e acompanhe documentos regulatórios da farmácia.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<div key={doc.name} className="flex items-center justify-between rounded bg-gray-50 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-800">{doc.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">Última atualização: {doc.lastUpdate}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||||
|
doc.status === 'validado' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{doc.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="rounded bg-healthGreen px-3 py-1 text-xs font-semibold text-white"
|
||||||
|
onClick={() => updateStatus(doc.name, doc.status === 'validado' ? 'pendente' : 'validado')}
|
||||||
|
>
|
||||||
|
Alternar status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
marketplace-front/src/services/apiClient.ts
Normal file
35
marketplace-front/src/services/apiClient.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 8000
|
||||||
|
})
|
||||||
|
|
||||||
|
let token: string | null = null
|
||||||
|
|
||||||
|
instance.interceptors.request.use((config) => {
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
console.warn('Sessão expirada, por favor, faça login novamente.')
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const apiClient = {
|
||||||
|
get: instance.get,
|
||||||
|
post: instance.post,
|
||||||
|
put: instance.put,
|
||||||
|
delete: instance.delete,
|
||||||
|
setToken: (value: string | null) => {
|
||||||
|
token = value
|
||||||
|
}
|
||||||
|
}
|
||||||
77
marketplace-front/src/stores/cartStore.ts
Normal file
77
marketplace-front/src/stores/cartStore.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
activeIngredient: string
|
||||||
|
lab: string
|
||||||
|
batch: string
|
||||||
|
expiry: string
|
||||||
|
vendorId: string
|
||||||
|
vendorName: string
|
||||||
|
unitPrice: number
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartState {
|
||||||
|
items: CartItem[]
|
||||||
|
addItem: (item: Omit<CartItem, 'quantity'>, quantity?: number) => void
|
||||||
|
updateQuantity: (id: string, quantity: number) => void
|
||||||
|
removeItem: (id: string) => void
|
||||||
|
clearVendor: (vendorId: string) => void
|
||||||
|
clearAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCartStore = create<CartState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
items: [],
|
||||||
|
addItem: (item, quantity = 1) => {
|
||||||
|
set((state) => {
|
||||||
|
const exists = state.items.find((i) => i.id === item.id)
|
||||||
|
if (exists) {
|
||||||
|
return {
|
||||||
|
items: state.items.map((i) =>
|
||||||
|
i.id === item.id ? { ...i, quantity: i.quantity + quantity } : i
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { items: [...state.items, { ...item, quantity }] }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateQuantity: (id, quantity) =>
|
||||||
|
set((state) => ({
|
||||||
|
items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i))
|
||||||
|
})),
|
||||||
|
removeItem: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
|
||||||
|
clearVendor: (vendorId) =>
|
||||||
|
set((state) => ({ items: state.items.filter((i) => i.vendorId !== vendorId) })),
|
||||||
|
clearAll: () => set({ items: [] })
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'mp-cart-storage',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const selectGroupedCart = (state: CartState) => {
|
||||||
|
const groups = state.items.reduce<Record<string, { vendorName: string; items: CartItem[]; total: number }>>(
|
||||||
|
(acc, item) => {
|
||||||
|
const current = acc[item.vendorId] ?? { vendorName: item.vendorName, items: [], total: 0 }
|
||||||
|
current.items.push(item)
|
||||||
|
current.total += item.quantity * item.unitPrice
|
||||||
|
acc[item.vendorId] = current
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectCartSummary = (state: CartState) => {
|
||||||
|
const totalItems = state.items.reduce((acc, item) => acc + item.quantity, 0)
|
||||||
|
const totalValue = state.items.reduce((acc, item) => acc + item.quantity * item.unitPrice, 0)
|
||||||
|
return { totalItems, totalValue }
|
||||||
|
}
|
||||||
12
marketplace-front/src/types/product.ts
Normal file
12
marketplace-front/src/types/product.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export interface Product {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
activeIngredient: string
|
||||||
|
lab: string
|
||||||
|
category: string
|
||||||
|
batch: string
|
||||||
|
expiry: string
|
||||||
|
vendorId: string
|
||||||
|
vendorName: string
|
||||||
|
price: number
|
||||||
|
}
|
||||||
14
marketplace-front/tailwind.config.ts
Normal file
14
marketplace-front/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
healthGreen: '#2D9CDB',
|
||||||
|
medicalBlue: '#0F4C81'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
} satisfies Config
|
||||||
19
marketplace-front/tsconfig.json
Normal file
19
marketplace-front/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
marketplace-front/tsconfig.node.json
Normal file
10
marketplace-front/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
9
marketplace-front/vite.config.ts
Normal file
9
marketplace-front/vite.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue