๐จ ํฌ๋ฆฌ์์ดํฐ ์ํ ํ๋งค ์ต - ๋์งํธ ์ํธ ๋ง์ผํ๋ ์ด์ค
digital-art-marketplace-laravel
๋ฌธ์ ์ค๋ช
๐ฏ ํ๋ก์ ํธ ๊ฐ์
์ผ๋ฌ์คํธ๋ ์ดํฐ, ๋์์ด๋, ์ฌ์ง์๊ฐ ๋ฑ ํฌ๋ฆฌ์์ดํฐ๊ฐ ์์ ์ ๋์งํธ ์ํ์ ํ๋งคํ ์ ์๋ ๋ฏธ๋ ๋ง์ผํ๋ ์ด์ค๋ฅผ ๋ง๋ญ๋๋ค. Laravel API๋ก ์ํ ๋ฐ์ดํฐ๋ฅผ ๊ด๋ฆฌํ๊ณ , React๋ก ์๊ฐ์ ์ผ๋ก ๋งค๋ ฅ์ ์ธ ๊ฐค๋ฌ๋ฆฌ ํํ์ ์ผํ ๊ฒฝํ์ ์ ๊ณตํฉ๋๋ค.
โจ ํ์ ๊ธฐ๋ฅ
- ์ํ ๊ฐค๋ฌ๋ฆฌ: ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ผ๋ก ์ํ ์ธ๋ค์ผ ํ์ (์ด๋ฏธ์ง, ์ ๋ชฉ, ๊ฐ๊ฒฉ)
- ์ํ ์์ธ ํ์ด์ง: ํฐ ์ด๋ฏธ์ง, ์์ธ ์ค๋ช , ํฌ๋ฆฌ์์ดํฐ ์ ๋ณด, ๊ตฌ๋งค ๋ฒํผ
- ์ฅ๋ฐ๊ตฌ๋ ๊ธฐ๋ฅ: React useState๋ก ์ฅ๋ฐ๊ตฌ๋ ๊ด๋ฆฌ, ์ด ๊ธ์ก ๊ณ์ฐ
- ๊ฐ๋จํ ๊ฒฐ์ ํผ: ๊ตฌ๋งค์ ์ ๋ณด ์ ๋ ฅ ํผ (์ด๋ฆ, ์ด๋ฉ์ผ, ๊ฒฐ์ ๋ฐฉ๋ฒ ์ ํ)
- ์ฃผ๋ฌธ ์๋ฃ ํ์ด์ง: ์ฃผ๋ฌธ ๋ฒํธ์ ๊ฐ์ฌ ๋ฉ์์ง ํ์
- ๋ฐ์ํ ๋์์ธ: ๋ชจ๋ฐ์ผ/ํ๋ธ๋ฆฟ/๋ฐ์คํฌํฑ ๋ชจ๋ ์ง์
๐ ๋ณด๋์ค ๊ธฐ๋ฅ
- ์ํ ์นดํ ๊ณ ๋ฆฌ ํํฐ๋ง (์ผ๋ฌ์คํธ, ์ฌ์ง, 3D ์ํธ ๋ฑ)
- ๊ฐ๊ฒฉ ๋ฒ์ ํํฐ ์ฌ๋ผ์ด๋
- ์ํ ์ข์์/์์๋ฆฌ์คํธ ๊ธฐ๋ฅ
- ํฌ๋ฆฌ์์ดํฐ ํ๋กํ ํ์ด์ง
- ์ต๊ทผ ๋ณธ ์ํ ํ์คํ ๋ฆฌ (localStorage)
- ์ํ ๊ฒ์ ๊ธฐ๋ฅ
- ์ธ๊ธฐ ์ํ / ์ ์ ์ ๋ ฌ ์ต์
๐จ ๋์์ธ ํฌ์ธํธ
- Tailwind CSS๋ก ์ธ๋ จ๋ ์นด๋ ๋์์ธ
- Hover ์ ์ํ ํ๋/๊ทธ๋ฆผ์ ํจ๊ณผ
- ๋ถ๋๋ฌ์ด ํ์ด์ง ์ ํ ์ ๋๋ฉ์ด์
- ์ฅ๋ฐ๊ตฌ๋ ์์ด์ฝ์ ์์ดํ ๊ฐ์ ๋ฑ์ง
- ๊ตฌ๋งค ๋ฒํผ ํด๋ฆญ ์ ๋ก๋ฉ ์คํผ๋
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x ๋ฐฑ์๋ API + React 19 ํ๋ก ํธ์๋(Vite) ๊ตฌ์ฑ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ
- โข Tailwind CSS๋ก ์คํ์ผ๋ง
- โข fetch ๋๋ axios๋ก API ํต์
- โข React Router DOM์ผ๋ก ํ์ด์ง ๋ผ์ฐํ
- โข useState/useEffect๋ง ์ฌ์ฉ (Redux ๋ฑ ๋ณต์กํ ์ํ๊ด๋ฆฌ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๊ธ์ง)
- โข ์ค์ ๊ฒฐ์ ์ฐ๋ ์์ด ํผ ์ ์ถ๋ง ๊ตฌํ
- โข ์ด๋ฏธ์ง๋ public ํด๋์ ์ํ ์ด๋ฏธ์ง ๋๋ ์ธ๋ถ URL ์ฌ์ฉ
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir digital-art-marketplace-laravel && cd digital-art-marketplace-laravel
์ด ํด๋์์ ๋์งํธ ์ํธ ๋ง์ผํ๋ ์ด์ค ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค.
**Laravel 13.x API ๋ฐฑ์๋ ์ค์ :**
1. Laravel 13.x ํ๋ก์ ํธ ์์ฑ:
```bash
composer create-project laravel/laravel:^13.0 backend
cd backend
```
2. .env ํ์ผ ์์ :
```
DB_CONNECTION=sqlite
# DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD ์ฃผ์ ์ฒ๋ฆฌ
```
3. SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ์ผ ์์ฑ:
```bash
touch database/database.sqlite
```
4. CORS ์ค์ (config/cors.php):
```php
'paths' => ['api/*'],
'allowed_origins' => ['http://localhost:5173'],
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
```
5. Artwork ๋ชจ๋ธ๊ณผ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:
```bash
php artisan make:model Artwork -m
```
6. ๋ง์ด๊ทธ๋ ์ด์
ํ์ผ ์์ฑ (database/migrations/xxxx_create_artworks_table.php):
```php
public function up()
{
Schema::create('artworks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description');
$table->string('category'); // illustration, photo, 3d-art, digital-painting
$table->decimal('price', 10, 2);
$table->string('image_url');
$table->string('creator_name');
$table->string('creator_avatar')->nullable();
$table->integer('likes')->default(0);
$table->timestamps();
});
}
```
7. ๋ง์ด๊ทธ๋ ์ด์
์คํ:
```bash
php artisan migrate
```
8. Seeder ์์ฑ ๋ฐ ์ํ ๋ฐ์ดํฐ ์ถ๊ฐ:
```bash
php artisan make:seeder ArtworkSeeder
```
database/seeders/ArtworkSeeder.php:
```php
public function run()
{
$artworks = [
[
'title' => 'Sunset Dreams',
'description' => '๋ฐ๋ปํ ์๊ฐ์ ์ผ๋ฌ์คํธ๋ ์ด์
',
'category' => 'illustration',
'price' => 29.99,
'image_url' => 'https://picsum.photos/seed/art1/800/600',
'creator_name' => 'Luna Park',
'creator_avatar' => 'https://i.pravatar.cc/150?img=1',
'likes' => 124,
],
[
'title' => 'Urban Geometry',
'description' => '๋์์ ๊ธฐํํ์ ํจํด',
'category' => 'photo',
'price' => 49.99,
'image_url' => 'https://picsum.photos/seed/art2/800/600',
'creator_name' => 'Alex Kim',
'creator_avatar' => 'https://i.pravatar.cc/150?img=2',
'likes' => 89,
],
[
'title' => 'Future City',
'description' => '๋ฏธ๋ ๋์ 3D ๋ ๋๋ง',
'category' => '3d-art',
'price' => 79.99,
'image_url' => 'https://picsum.photos/seed/art3/800/600',
'creator_name' => 'Chris Lee',
'creator_avatar' => 'https://i.pravatar.cc/150?img=3',
'likes' => 203,
],
[
'title' => 'Ocean Waves',
'description' => 'ํ๋์ ์ญ๋์ฑ์ ๋ด์ ๋์งํธ ํ์ธํ
',
'category' => 'digital-painting',
'price' => 39.99,
'image_url' => 'https://picsum.photos/seed/art4/800/600',
'creator_name' => 'Emma Stone',
'creator_avatar' => 'https://i.pravatar.cc/150?img=4',
'likes' => 156,
],
[
'title' => 'Neon Nights',
'description' => '๋ค์จ ์ฌ์ธ์ด ๋น๋๋ ๋ฐค๊ฑฐ๋ฆฌ',
'category' => 'illustration',
'price' => 34.99,
'image_url' => 'https://picsum.photos/seed/art5/800/600',
'creator_name' => 'Luna Park',
'creator_avatar' => 'https://i.pravatar.cc/150?img=1',
'likes' => 178,
],
[
'title' => 'Mountain Peak',
'description' => '์ฅ์ํ ์ฐ์ ์ ์',
'category' => 'photo',
'price' => 44.99,
'image_url' => 'https://picsum.photos/seed/art6/800/600',
'creator_name' => 'Alex Kim',
'creator_avatar' => 'https://i.pravatar.cc/150?img=2',
'likes' => 92,
],
];
foreach ($artworks as $artwork) {
\App\Models\Artwork::create($artwork);
}
}
```
9. Seeder ์คํ:
```bash
php artisan db:seed --class=ArtworkSeeder
```
10. API ์ปจํธ๋กค๋ฌ ์์ฑ:
```bash
php artisan make:controller Api/ArtworkController
```
app/Http/Controllers/Api/ArtworkController.php:
```php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use Illuminate\Http\Request;
class ArtworkController extends Controller
{
public function index()
{
$artworks = Artwork::orderBy('created_at', 'desc')->get();
return response()->json($artworks);
}
public function show($id)
{
$artwork = Artwork::findOrFail($id);
return response()->json($artwork);
}
}
```
11. API ๋ผ์ฐํธ ์ค์ (routes/api.php):
```php
use App\Http\Controllers\Api\ArtworkController;
Route::get('/artworks', [ArtworkController::class, 'index']);
Route::get('/artworks/{id}', [ArtworkController::class, 'show']);
```
12. Laravel ์๋ฒ ์คํ:
```bash
php artisan serve
```
๋ธ๋ผ์ฐ์ ์์ http://localhost:8000/api/artworks ์ ์ํ์ฌ JSON ๋ฐ์ดํฐ ํ์ธ.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x API ์๋ฒ๊ฐ http://localhost:8000์์ ์คํ๋๊ณ , /api/artworks ์๋ํฌ์ธํธ์์ ์ํ ๋ชฉ๋ก JSON ๋ฐ์ดํฐ๋ฅผ ๋ฐํํฉ๋๋ค. SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์ 6๊ฐ์ ์ํ ์ํ์ด ์ ์ฅ๋์ด ์์ต๋๋ค.
**React 19 ํ๋ก ํธ์๋ ์ค์ :**
1. ํ๋ก์ ํธ ๋ฃจํธ๋ก ๋์๊ฐ์ React + Vite ํ๋ก์ ํธ ์์ฑ:
```bash
cd ..
npm create vite@latest frontend -- --template react
cd frontend
npm install
```
2. Tailwind CSS ์ค์น:
```bash
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
```
3. tailwind.config.js ์์ :
```js
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
```
4. src/index.css์ Tailwind ๋๋ ํฐ๋ธ ์ถ๊ฐ:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
5. React Router DOM ์ค์น:
```bash
npm install react-router-dom
```
6. src/App.jsx๋ฅผ ๋ผ์ฐํฐ๋ก ๊ตฌ์ฑ:
```jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import HomePage from './pages/HomePage'
import ArtworkDetailPage from './pages/ArtworkDetailPage'
import CartPage from './pages/CartPage'
import CheckoutPage from './pages/CheckoutPage'
import ThankYouPage from './pages/ThankYouPage'
import Header from './components/Header'
import { CartProvider } from './context/CartContext'
function App() {
return (
<CartProvider>
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
<Header />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/artwork/:id" element={<ArtworkDetailPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/thank-you" element={<ThankYouPage />} />
</Routes>
</div>
</BrowserRouter>
</CartProvider>
)
}
export default App
```
7. ์ฅ๋ฐ๊ตฌ๋ Context ์์ฑ (src/context/CartContext.jsx):
```jsx
import { createContext, useState, useContext } from 'react'
const CartContext = createContext()
export function CartProvider({ children }) {
const [cartItems, setCartItems] = useState([])
const addToCart = (artwork) => {
setCartItems(prev => {
const existing = prev.find(item => item.id === artwork.id)
if (existing) {
return prev.map(item =>
item.id === artwork.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
return [...prev, { ...artwork, quantity: 1 }]
})
}
const removeFromCart = (id) => {
setCartItems(prev => prev.filter(item => item.id !== id))
}
const updateQuantity = (id, quantity) => {
if (quantity === 0) {
removeFromCart(id)
} else {
setCartItems(prev =>
prev.map(item =>
item.id === id ? { ...item, quantity } : item
)
)
}
}
const getTotalPrice = () => {
return cartItems.reduce((sum, item) => sum + (item.price * item.quantity), 0)
}
const getTotalItems = () => {
return cartItems.reduce((sum, item) => sum + item.quantity, 0)
}
const clearCart = () => {
setCartItems([])
}
return (
<CartContext.Provider value={{
cartItems,
addToCart,
removeFromCart,
updateQuantity,
getTotalPrice,
getTotalItems,
clearCart
}}>
{children}
</CartContext.Provider>
)
}
export function useCart() {
return useContext(CartContext)
}
```
8. Header ์ปดํฌ๋ํธ ์์ฑ (src/components/Header.jsx):
```jsx
import { Link } from 'react-router-dom'
import { useCart } from '../context/CartContext'
function Header() {
const { getTotalItems } = useCart()
const totalItems = getTotalItems()
return (
<header className="bg-white shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
<Link to="/" className="text-2xl font-bold text-indigo-600">
๐จ ArtMarket
</Link>
<Link to="/cart" className="relative">
<svg className="w-8 h-8 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
{totalItems > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
{totalItems}
</span>
)}
</Link>
</div>
</header>
)
}
export default Header
```
9. ํ์ด์ง ํด๋ ์์ฑ ๋ฐ ๊ธฐ๋ณธ ์ปดํฌ๋ํธ ํ์ผ ์์ฑ:
```bash
mkdir -p src/pages src/components src/context
touch src/pages/HomePage.jsx src/pages/ArtworkDetailPage.jsx src/pages/CartPage.jsx src/pages/CheckoutPage.jsx src/pages/ThankYouPage.jsx
```
10. ๊ฐ๋ฐ ์๋ฒ ์คํ:
```bash
npm run dev
```
http://localhost:5173 ์ ์ํ์ฌ Header๊ฐ ํ์๋๋์ง ํ์ธ.
๊ธฐ๋ ๊ฒฐ๊ณผ: React ํ๋ก ํธ์๋๊ฐ http://localhost:5173์์ ์คํ๋๊ณ , Tailwind CSS๊ฐ ์ ์ฉ๋ Header์ ๋ผ์ฐํฐ ๊ตฌ์กฐ๊ฐ ์ค์ ๋์ด ์์ต๋๋ค. CartContext๋ฅผ ํตํด ์ฅ๋ฐ๊ตฌ๋ ์ํ ๊ด๋ฆฌ๊ฐ ์ค๋น๋์ด ์์ต๋๋ค.
**ํํ์ด์ง ์ํ ๊ฐค๋ฌ๋ฆฌ ๊ตฌํ:**
src/pages/HomePage.jsx:
```jsx
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
function HomePage() {
const [artworks, setArtworks] = useState([])
const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState('all')
useEffect(() => {
fetch('http://localhost:8000/api/artworks')
.then(res => res.json())
.then(data => {
setArtworks(data)
setLoading(false)
})
.catch(err => {
console.error('Error fetching artworks:', err)
setLoading(false)
})
}, [])
const categories = [
{ value: 'all', label: '์ ์ฒด' },
{ value: 'illustration', label: '์ผ๋ฌ์คํธ' },
{ value: 'photo', label: '์ฌ์ง' },
{ value: '3d-art', label: '3D ์ํธ' },
{ value: 'digital-painting', label: '๋์งํธ ํ์ธํ
' },
]
const filteredArtworks = selectedCategory === 'all'
? artworks
: artworks.filter(art => art.category === selectedCategory)
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
)
}
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">๋์งํธ ์ํธ ๋ง์ผํ๋ ์ด์ค</h1>
<p className="text-gray-600 text-lg">ํฌ๋ฆฌ์์ดํฐ์ ๋ฉ์ง ์ํ์ ๋ง๋๋ณด์ธ์</p>
</div>
{/* ์นดํ
๊ณ ๋ฆฌ ํํฐ */}
<div className="flex justify-center gap-4 mb-8 flex-wrap">
{categories.map(cat => (
<button
key={cat.value}
onClick={() => setSelectedCategory(cat.value)}
className={`px-6 py-2 rounded-full font-medium transition-all ${
selectedCategory === cat.value
? 'bg-indigo-600 text-white shadow-lg'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
>
{cat.label}
</button>
))}
</div>
{/* ์ํ ๊ทธ๋ฆฌ๋ */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredArtworks.map(artwork => (
<Link
key={artwork.id}
to={`/artwork/${artwork.id}`}
className="group bg-white rounded-xl shadow-md overflow-hidden hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2"
>
<div className="aspect-[4/3] overflow-hidden">
<img
src={artwork.image_url}
alt={artwork.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
/>
</div>
<div className="p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">{artwork.title}</h3>
<div className="flex items-center gap-2 mb-3">
<img
src={artwork.creator_avatar}
alt={artwork.creator_name}
className="w-8 h-8 rounded-full"
/>
<span className="text-sm text-gray-600">{artwork.creator_name}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-2xl font-bold text-indigo-600">${artwork.price}</span>
<div className="flex items-center gap-1 text-gray-500">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
<span className="text-sm">{artwork.likes}</span>
</div>
</div>
</div>
</Link>
))}
</div>
{filteredArtworks.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">ํด๋น ์นดํ
๊ณ ๋ฆฌ์ ์ํ์ด ์์ต๋๋ค.</p>
</div>
)}
</div>
)
}
export default HomePage
```
๋ธ๋ผ์ฐ์ ์์ http://localhost:5173 ์ ์ํ์ฌ ์ํ ๊ฐค๋ฌ๋ฆฌ๊ฐ ํ์๋๊ณ , ์นดํ
๊ณ ๋ฆฌ ํํฐ๊ฐ ๋์ํ๋์ง ํ์ธํ์ธ์. ์นด๋์ ๋ง์ฐ์ค๋ฅผ ์ฌ๋ฆฌ๋ฉด ํ๋ ํจ๊ณผ๊ฐ ๋ํ๋์ผ ํฉ๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ํํ์ด์ง์ ์ํ ๊ฐค๋ฌ๋ฆฌ๊ฐ ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ผ๋ก ํ์๋๊ณ , ์นดํ ๊ณ ๋ฆฌ ํํฐ ๋ฒํผ์ผ๋ก ์ํ์ ํํฐ๋งํ ์ ์์ต๋๋ค. ๊ฐ ์นด๋๋ hover ์ ํ๋๋๊ณ ๊ทธ๋ฆผ์๊ฐ ์งํด์ง๋ ์ ๋๋ฉ์ด์ ํจ๊ณผ๊ฐ ์์ต๋๋ค.
**์ํ ์์ธ ํ์ด์ง ๋ฐ ์ฅ๋ฐ๊ตฌ๋ ๋ด๊ธฐ ๊ธฐ๋ฅ ๊ตฌํ:**
src/pages/ArtworkDetailPage.jsx:
```jsx
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useCart } from '../context/CartContext'
function ArtworkDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const { addToCart } = useCart()
const [artwork, setArtwork] = useState(null)
const [loading, setLoading] = useState(true)
const [addedToCart, setAddedToCart] = useState(false)
useEffect(() => {
fetch(`http://localhost:8000/api/artworks/${id}`)
.then(res => res.json())
.then(data => {
setArtwork(data)
setLoading(false)
})
.catch(err => {
console.error('Error fetching artwork:', err)
setLoading(false)
})
}, [id])
const handleAddToCart = () => {
addToCart(artwork)
setAddedToCart(true)
setTimeout(() => setAddedToCart(false), 2000)
}
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
)
}
if (!artwork) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<p className="text-gray-500 text-lg">์ํ์ ์ฐพ์ ์ ์์ต๋๋ค.</p>
</div>
)
}
const categoryLabels = {
'illustration': '์ผ๋ฌ์คํธ',
'photo': '์ฌ์ง',
'3d-art': '3D ์ํธ',
'digital-painting': '๋์งํธ ํ์ธํ
'
}
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-8"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
๋ค๋ก ๊ฐ๊ธฐ
</button>
<div className="grid md:grid-cols-2 gap-12">
{/* ์ด๋ฏธ์ง */}
<div className="bg-white rounded-2xl shadow-lg overflow-hidden">
<img
src={artwork.image_url}
alt={artwork.title}
className="w-full h-full object-cover"
/>
</div>
{/* ์ ๋ณด */}
<div>
<span className="inline-block px-4 py-1 bg-indigo-100 text-indigo-700 rounded-full text-sm font-medium mb-4">
{categoryLabels[artwork.category]}
</span>
<h1 className="text-4xl font-bold text-gray-900 mb-4">{artwork.title}</h1>
<div className="flex items-center gap-3 mb-6">
<img
src={artwork.creator_avatar}
alt={artwork.creator_name}
className="w-12 h-12 rounded-full"
/>
<div>
<p className="font-medium text-gray-900">{artwork.creator_name}</p>
<p className="text-sm text-gray-500">ํฌ๋ฆฌ์์ดํฐ</p>
</div>
</div>
<div className="flex items-center gap-3 mb-6">
<svg className="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
<span className="text-gray-600">{artwork.likes}๋ช
์ด ์ข์ํฉ๋๋ค</span>
</div>
<p className="text-gray-700 text-lg leading-relaxed mb-8">{artwork.description}</p>
<div className="border-t border-gray-200 pt-8">
<div className="flex items-end justify-between mb-6">
<div>
<p className="text-gray-500 text-sm mb-1">๊ฐ๊ฒฉ</p>
<p className="text-4xl font-bold text-indigo-600">${artwork.price}</p>
</div>
</div>
<button
onClick={handleAddToCart}
disabled={addedToCart}
className={`w-full py-4 rounded-xl font-bold text-lg transition-all ${
addedToCart
? 'bg-green-500 text-white'
: 'bg-indigo-600 text-white hover:bg-indigo-700 active:scale-95'
}`}
>
{addedToCart ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
์ฅ๋ฐ๊ตฌ๋์ ์ถ๊ฐ๋จ!
</span>
) : (
'์ฅ๋ฐ๊ตฌ๋์ ๋ด๊ธฐ'
)}
</button>
<button
onClick={() => navigate('/cart')}
className="w-full mt-3 py-4 border-2 border-indigo-600 text-indigo-600 rounded-xl font-bold text-lg hover:bg-indigo-50 transition-all"
>
์ฅ๋ฐ๊ตฌ๋ ๋ณด๊ธฐ
</button>
</div>
</div>
</div>
</div>
)
}
export default ArtworkDetailPage
```
์ํ ์นด๋๋ฅผ ํด๋ฆญํ์ฌ ์์ธ ํ์ด์ง๋ก ์ด๋ํ๊ณ , '์ฅ๋ฐ๊ตฌ๋์ ๋ด๊ธฐ' ๋ฒํผ์ ํด๋ฆญํ๋ฉด ์ฅ๋ฐ๊ตฌ๋์ ์ถ๊ฐ๋๊ณ ๋ฒํผ์ด ์ด๋ก์์ผ๋ก ๋ณํ๋ฉฐ ์ฒดํฌ ์์ด์ฝ์ด ํ์๋๋์ง ํ์ธํ์ธ์.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ํ ์์ธ ํ์ด์ง์์ ํฐ ์ด๋ฏธ์ง, ์ ๋ชฉ, ์ค๋ช , ํฌ๋ฆฌ์์ดํฐ ์ ๋ณด, ๊ฐ๊ฒฉ์ด ํ์๋ฉ๋๋ค. '์ฅ๋ฐ๊ตฌ๋์ ๋ด๊ธฐ' ๋ฒํผ์ ํด๋ฆญํ๋ฉด ์ฅ๋ฐ๊ตฌ๋์ ์ํ์ด ์ถ๊ฐ๋๊ณ , ๋ฒํผ์ด 2์ด๊ฐ ์ด๋ก์์ผ๋ก ๋ณํ๋ฉฐ ํผ๋๋ฐฑ์ ์ ๊ณตํฉ๋๋ค. Header์ ์ฅ๋ฐ๊ตฌ๋ ์์ด์ฝ์ ๋ฑ์ง ์ซ์๊ฐ ์ฆ๊ฐํฉ๋๋ค.
**์ฅ๋ฐ๊ตฌ๋ ํ์ด์ง ๋ฐ ๊ฒฐ์ ํ๋ฆ ์์ฑ:**
1. src/pages/CartPage.jsx:
```jsx
import { useNavigate } from 'react-router-dom'
import { useCart } from '../context/CartContext'
function CartPage() {
const navigate = useNavigate()
const { cartItems, removeFromCart, updateQuantity, getTotalPrice } = useCart()
if (cartItems.length === 0) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<svg className="w-24 h-24 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<h2 className="text-2xl font-bold text-gray-900 mb-4">์ฅ๋ฐ๊ตฌ๋๊ฐ ๋น์ด์์ต๋๋ค</h2>
<p className="text-gray-600 mb-6">๋ฉ์ง ์ํ์ ๋๋ฌ๋ณด๊ณ ๋ง์์ ๋๋ ์ํ์ ๋ด์๋ณด์ธ์!</p>
<button
onClick={() => navigate('/')}
className="bg-indigo-600 text-white px-8 py-3 rounded-xl font-medium hover:bg-indigo-700 transition-all"
>
์ํ ๋๋ฌ๋ณด๊ธฐ
</button>
</div>
)
}
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold text-gray-900 mb-8">์ฅ๋ฐ๊ตฌ๋</h1>
<div className="grid lg:grid-cols-3 gap-8">
{/* ์ฅ๋ฐ๊ตฌ๋ ์์ดํ
*/}
<div className="lg:col-span-2 space-y-4">
{cartItems.map(item => (
<div key={item.id} className="bg-white rounded-xl shadow-md p-6 flex gap-6">
<img
src={item.image_url}
alt={item.title}
className="w-32 h-32 object-cover rounded-lg"
/>
<div className="flex-1">
<h3 className="text-xl font-bold text-gray-900 mb-2">{item.title}</h3>
<p className="text-gray-600 mb-2">{item.creator_name}</p>
<p className="text-2xl font-bold text-indigo-600">${item.price}</p>
</div>
<div className="flex flex-col justify-between items-end">
<button
onClick={() => removeFromCart(item.id)}
className="text-red-500 hover:text-red-700"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<div className="flex items-center gap-3">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
className="w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 flex items-center justify-center"
>
-
</button>
<span className="font-bold text-lg">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
className="w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300 flex items-center justify-center"
>
+
</button>
</div>
</div>
</div>
))}
</div>
{/* ์ฃผ๋ฌธ ์์ฝ */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl shadow-md p-6 sticky top-24">
<h2 className="text-xl font-bold text-gray-900 mb-4">์ฃผ๋ฌธ ์์ฝ</h2>
<div className="space-y-3 mb-6">
<div className="flex justify-between text-gray-600">
<span>์๊ณ</span>
<span>${getTotalPrice().toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-600">
<span>๋ฐฐ์ก๋น</span>
<span className="text-green-600">๋ฌด๋ฃ</span>
</div>
<div className="border-t pt-3 flex justify-between text-xl font-bold">
<span>์ด ๊ธ์ก</span>
<span className="text-indigo-600">${getTotalPrice().toFixed(2)}</span>
</div>
</div>
<button
onClick={() => navigate('/checkout')}
className="w-full bg-indigo-600 text-white py-4 rounded-xl font-bold text-lg hover:bg-indigo-700 active:scale-95 transition-all"
>
๊ฒฐ์ ํ๊ธฐ
</button>
<button
onClick={() => navigate('/')}
className="w-full mt-3 text-indigo-600 py-3 rounded-xl font-medium hover:bg-indigo-50 transition-all"
>
์ผํ ๊ณ์ํ๊ธฐ
</button>
</div>
</div>
</div>
</div>
)
}
export default CartPage
```
2. src/pages/CheckoutPage.jsx:
```jsx
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useCart } from '../context/CartContext'
function CheckoutPage() {
const navigate = useNavigate()
const { cartItems, getTotalPrice, clearCart } = useCart()
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
address: '',
paymentMethod: 'credit-card'
})
const [loading, setLoading] = useState(false)
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleSubmit = (e) => {
e.preventDefault()
setLoading(true)
// ๊ฒฐ์ ์ฒ๋ฆฌ ์๋ฎฌ๋ ์ด์
(2์ด ํ ์๋ฃ)
setTimeout(() => {
const orderNumber = 'ORD-' + Date.now()
clearCart()
navigate('/thank-you', { state: { orderNumber, ...formData } })
}, 2000)
}
if (cartItems.length === 0) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<p className="text-gray-600 mb-4">์ฅ๋ฐ๊ตฌ๋๊ฐ ๋น์ด์์ต๋๋ค.</p>
<button
onClick={() => navigate('/')}
className="bg-indigo-600 text-white px-8 py-3 rounded-xl font-medium hover:bg-indigo-700"
>
์ํ ๋๋ฌ๋ณด๊ธฐ
</button>
</div>
)
}
return (
<div className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold text-gray-900 mb-8">๊ฒฐ์ ํ๊ธฐ</h1>
<div className="grid md:grid-cols-3 gap-8">
<form onSubmit={handleSubmit} className="md:col-span-2 space-y-6">
{/* ๊ตฌ๋งค์ ์ ๋ณด */}
<div className="bg-white rounded-xl shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">๊ตฌ๋งค์ ์ ๋ณด</h2>
<div className="space-y-4">
<div>
<label className="block text-gray-700 font-medium mb-2">์ด๋ฆ</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-gray-700 font-medium mb-2">์ด๋ฉ์ผ</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-gray-700 font-medium mb-2">์ ํ๋ฒํธ</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-gray-700 font-medium mb-2">์ฃผ์</label>
<textarea
name="address"
value={formData.address}
onChange={handleChange}
required
rows={3}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
</div>
{/* ๊ฒฐ์ ๋ฐฉ๋ฒ */}
<div className="bg-white rounded-xl shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">๊ฒฐ์ ๋ฐฉ๋ฒ</h2>
<div className="space-y-3">
{[
{ value: 'credit-card', label: '์ ์ฉ์นด๋' },
{ value: 'paypal', label: 'PayPal' },
{ value: 'bank-transfer', label: '๊ณ์ข์ด์ฒด' }
].map(method => (
<label key={method.value} className="flex items-center gap-3 cursor-pointer">
<input
type="radio"
name="paymentMethod"
value={method.value}
checked={formData.paymentMethod === method.value}
onChange={handleChange}
className="w-5 h-5 text-indigo-600"
/>
<span className="text-gray-700">{method.label}</span>
</label>
))}
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 text-white py-4 rounded-xl font-bold text-lg hover:bg-indigo-700 active:scale-95 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
๊ฒฐ์ ์ฒ๋ฆฌ ์ค...
</span>
) : (
`$${getTotalPrice().toFixed(2)} ๊ฒฐ์ ํ๊ธฐ`
)}
</button>
</form>
{/* ์ฃผ๋ฌธ ์์ฝ */}
<div className="md:col-span-1">
<div className="bg-white rounded-xl shadow-md p-6 sticky top-24">
<h2 className="text-lg font-bold text-gray-900 mb-4">์ฃผ๋ฌธ ์์ฝ</h2>
<div className="space-y-3 mb-4">
{cartItems.map(item => (
<div key={item.id} className="flex justify-between text-sm">
<span className="text-gray-600">{item.title} x{item.quantity}</span>
<span className="font-medium">${(item.price * item.quantity).toFixed(2)}</span>
</div>
))}
</div>
<div className="border-t pt-3 flex justify-between text-lg font-bold">
<span>์ด ๊ธ์ก</span>
<span className="text-indigo-600">${getTotalPrice().toFixed(2)}</span>
</div>
</div>
</div>
</div>
</div>
)
}
export default CheckoutPage
```
3. src/pages/ThankYouPage.jsx:
```jsx
import { useLocation, useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
function ThankYouPage() {
const location = useLocation()
const navigate = useNavigate()
const orderData = location.state
useEffect(() => {
if (!orderData) {
navigate('/')
}
}, [orderData, navigate])
if (!orderData) return null
return (
<div className="max-w-2xl mx-auto px-4 py-12 text-center">
<div className="bg-white rounded-2xl shadow-lg p-12">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-12 h-12 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">์ฃผ๋ฌธ์ด ์๋ฃ๋์์ต๋๋ค!</h1>
<p className="text-gray-600 text-lg mb-8">๊ตฌ๋งคํด์ฃผ์
์ ๊ฐ์ฌํฉ๋๋ค. ์ํ์ ์ฆ๊ฒจ๋ณด์ธ์!</p>
<div className="bg-gray-50 rounded-xl p-6 mb-8">
<p className="text-sm text-gray-500 mb-2">์ฃผ๋ฌธ ๋ฒํธ</p>
<p className="text-2xl font-bold text-indigo-600 mb-6">{orderData.orderNumber}</p>
<div className="text-left space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">์ด๋ฆ:</span>
<span className="font-medium">{orderData.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">์ด๋ฉ์ผ:</span>
<span className="font-medium">{orderData.email}</span>
</div>
</div>
</div>
<p className="text-gray-600 mb-6">์ฃผ๋ฌธ ํ์ธ ์ด๋ฉ์ผ์ด {orderData.email}๋ก ๋ฐ์ก๋์์ต๋๋ค.</p>
<button
onClick={() => navigate('/')}
className="bg-indigo-600 text-white px-8 py-4 rounded-xl font-bold text-lg hover:bg-indigo-700 active:scale-95 transition-all"
>
ํ์ผ๋ก ๋์๊ฐ๊ธฐ
</button>
</div>
</div>
)
}
export default ThankYouPage
```
์ฅ๋ฐ๊ตฌ๋์์ '๊ฒฐ์ ํ๊ธฐ' ๋ฒํผ์ ํด๋ฆญํ๊ณ , ๊ฒฐ์ ์ ๋ณด๋ฅผ ์
๋ ฅํ ํ ๊ฒฐ์ ๋ฅผ ์๋ฃํ๋ฉด ์ฃผ๋ฌธ ์๋ฃ ํ์ด์ง๊ฐ ํ์๋๋์ง ํ์ธํ์ธ์. ์ฅ๋ฐ๊ตฌ๋๊ฐ ๋น์์ง๊ณ ์ฃผ๋ฌธ ๋ฒํธ๊ฐ ์์ฑ๋์ด์ผ ํฉ๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ฅ๋ฐ๊ตฌ๋ ํ์ด์ง์์ ์๋ ์กฐ์ , ์์ดํ ์ญ์ , ์ด ๊ธ์ก ๊ณ์ฐ์ด ๋์ํฉ๋๋ค. ๊ฒฐ์ ํ์ด์ง์์ ๊ตฌ๋งค์ ์ ๋ณด์ ๊ฒฐ์ ๋ฐฉ๋ฒ์ ์ ๋ ฅํ๊ณ ์ ์ถํ๋ฉด 2์ด ํ ์ฃผ๋ฌธ ์๋ฃ ํ์ด์ง๋ก ์ด๋ํ๋ฉฐ, ์ฃผ๋ฌธ ๋ฒํธ์ ๊ฐ์ฌ ๋ฉ์์ง๊ฐ ํ์๋ฉ๋๋ค. ์ ์ฒด ๊ตฌ๋งค ํ๋ฆ์ด ์์ฑ๋์์ต๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!