๐จ ํ๋ฆฌ๋์ ๋์์ด๋ ํฌํธํด๋ฆฌ์ค ์ต - ์ํ ๊ฐค๋ฌ๋ฆฌ & ๊ฒฌ์ ๋ฌธ์ ํตํฉ ํ๋ซํผ
designer-portfolio-inquiry-shop
๋ฌธ์ ์ค๋ช
ํ๋ก์ ํธ ๊ฐ์
ํ๋ฆฌ๋์ ๋์์ด๋๋ฅผ ์ํ ํฌํธํด๋ฆฌ์ค ๊ฒธ ๊ฒฌ์ ๋ฌธ์ ํตํฉ ํ๋ซํผ์ ๋ง๋ญ๋๋ค. ๋จ์ํ ์ํ์ ๋ณด์ฌ์ฃผ๋ ๊ฒ์ ๋์ด, ์ ์ฌ ๊ณ ๊ฐ์ด ์ง์ ์ํ๋ ์๋น์ค๋ฅผ ์ ํํ๊ณ ๊ฒฌ์ ์ ์์ฒญํ ์ ์๋ ์ค์ฉ์ ์ธ ๋น์ฆ๋์ค ๋๊ตฌ์ ๋๋ค.
Laravel 13.x + Inertia.js + React 19 ๋ชจ๋
ธ๋ฆฌ์ค ํ์คํ ์ํคํ
์ฒ๋ก ๊ตฌํํ๋ฉฐ, Inertia.js๊ฐ Laravel ๋ผ์ฐํธ์ React ์ปดํฌ๋ํธ๋ฅผ ์์ฐ์ค๋ฝ๊ฒ ์ฐ๊ฒฐํฉ๋๋ค. ๋ณ๋ API ์ค๊ณ ์์ด Laravel ์ปจํธ๋กค๋ฌ์์ Inertia::render()๋ก ๋ฐ์ดํฐ๋ฅผ ์ ๋ฌํ๋ ๋ฐฉ์์
๋๋ค.
ํ์ ๊ธฐ๋ฅ
- ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ: ์ํ ์ด๋ฏธ์ง ์ ๋ก๋, ์นดํ ๊ณ ๋ฆฌ๋ณ ํํฐ๋ง (๋ก๊ณ /์น๋์์ธ/ํจํค์ง ๋ฑ), ๊ทธ๋ฆฌ๋/๋ฆฌ์คํธ ๋ทฐ ์ ํ
- ์ํ ์์ธ ํ์ด์ง: ๋ผ์ดํธ๋ฐ์ค ๋ชจ๋ฌ, ํ๋ก์ ํธ ์ค๋ช , ์ฌ์ฉ ํด ํ๊ทธ, ์ด์ /๋ค์ ์ํ ๋ค๋น๊ฒ์ด์
- ์๋น์ค ๋ฉ๋ด: ์ ๊ณต ์๋น์ค๋ณ ๊ฐ๊ฒฉํ (๋ก๊ณ ๋์์ธ, ๋ช ํจ ๋์์ธ, ์น ๋ฐฐ๋ ๋ฑ), ํจํค์ง ์ต์
- ๊ฒฌ์ ๋ฌธ์ ํผ: ์๋น์ค ์ ํ, ํ๋ก์ ํธ ์์ธ ์ ๋ ฅ, ์์ฐ ๋ฒ์, ํฌ๋ง ์ผ์ , ์ฐธ๊ณ ์ด๋ฏธ์ง ์ ๋ก๋
- ๋ฌธ์ ๋ด์ญ ๊ด๋ฆฌ: ๋์์ด๋๊ฐ ๋ฐ์ ๊ฒฌ์ ์์ฒญ ๋ชฉ๋ก ํ์ธ, ์ํ ๋ณ๊ฒฝ (๊ฒํ ์ค/๋ต๋ณ์๋ฃ/์งํ์ค/์๋ฃ)
- ๋ฐ์ํ ๋์์ธ: Tailwind CSS๋ก ๋ชจ๋ฐ์ผ ์นํ์ UI
๋ณด๋์ค ๊ธฐ๋ฅ
- ์ํ ์ข์์ ๊ธฐ๋ฅ (๋ก๊ทธ์ธ ์์ด localStorage ํ์ฉ)
- ์ํ ๊ฒ์ ๊ธฐ๋ฅ (์ ๋ชฉ, ์ค๋ช , ํ๊ทธ)
- ๋คํฌ๋ชจ๋ ํ ๊ธ
- ๊ฒฌ์ ์์ฒญ ์ ์๋ ์ด๋ฉ์ผ ์๋ฆผ (Laravel Mail)
- ์ํ๋ณ ์กฐํ์ ์ง๊ณ
๊ธฐ์ ์คํ
- Backend: Laravel 13.x (Eloquent ORM, ๋ง์ด๊ทธ๋ ์ด์ , ์ปจํธ๋กค๋ฌ, Validation)
- Frontend: React 19 (Hooks: useState, useEffect, useForm)
- Bridge: Inertia.js (SSR ์คํ์ผ SPA)
- Styling: Tailwind CSS 4.x
- Database: SQLite (๊ฐ๋ฐ ํธ์์ฑ)
- File Upload: Laravel Storage (public disk)
ํ์ต ํฌ์ธํธ
- Inertia.js์ ์ฅ์ : API ์์ด Laravel โ React ๋ฐ์ดํฐ ํ๋ฆ ์ดํด
- ํ์ผ ์ ๋ก๋ ์ฒ๋ฆฌ (์ด๋ฏธ์ง ์ ์ฅ ๋ฐ ๊ฒ์ฆ)
- ํผ ์ํ ๊ด๋ฆฌ ๋ฐ ์ ํจ์ฑ ๊ฒ์ฌ (Laravel Validation + React)
- ๋์ ๋ผ์ฐํ ๋ฐ ํ์ด์ง๋ค์ด์
- ์ปดํฌ๋ํธ ์ฌ์ฌ์ฉ (์นด๋, ๋ชจ๋ฌ, ํผ ํ๋)
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ๊ตฌ์กฐ ํ์
- โข Laravel Breeze (React + Inertia) starter kit ์ฌ์ฉ ๊ถ์ฅ
- โข ๋ณ๋ REST API ๋ผ์ฐํธ ์์ฑ ๊ธ์ง (Inertia.js ์ฌ์ฉ)
- โข Tailwind CSS 4.x๋ก ์คํ์ผ๋ง
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๋ง์ด๊ทธ๋ ์ด์ ํ์ผ ์์ฑ)
- โข ์ด๋ฏธ์ง ์ ๋ก๋๋ Laravel Storage์ public disk ํ์ฉ
- โข ๋ฐ์ํ ๋์์ธ ํ์ (๋ชจ๋ฐ์ผ ์ฐ์ )
- โข ํผ ์ ์ถ ์ Laravel Validation์ผ๋ก ๋ฐฑ์๋ ๊ฒ์ฆ
- โข Inertia Form Helper ํ์ฉํ์ฌ React์์ ํผ ์ํ ๊ด๋ฆฌ
- โข ์ฝ๋ ์ฃผ์ ๋ฐ ๋ช ํํ ๋ณ์๋ช ์ฌ์ฉ
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir designer-portfolio-inquiry-shop && cd designer-portfolio-inquiry-shop ์ด ํด๋์์ Laravel + Inertia.js + React ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. **ํ๋ก์ ํธ ์ด๊ธฐํ:** 1. Laravel Breeze (React + Inertia) starter kit์ผ๋ก ํ๋ก์ ํธ ์์ฑ: ```bash composer create-project laravel/laravel . composer require laravel/breeze --dev php artisan breeze:install react npm install npm run dev ``` 2. `.env` ํ์ผ ์ค์ : - `DB_CONNECTION=sqlite` ๋ณ๊ฒฝ - `DB_DATABASE` ์ฃผ์ ์ฒ๋ฆฌ ๋๋ ์ ๋ ๊ฒฝ๋ก๋ก `database/database.sqlite` ์ง์ 3. SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ์ผ ์์ฑ: ```bash touch database/database.sqlite ``` 4. ๊ธฐ๋ณธ ๋ง์ด๊ทธ๋ ์ด์ ์คํ: ```bash php artisan migrate ``` 5. Storage ์ฌ๋ณผ๋ฆญ ๋งํฌ ์์ฑ (์ด๋ฏธ์ง ์ ๋ก๋์ฉ): ```bash php artisan storage:link ``` 6. Tailwind CSS ์ค์ ํ์ธ (`tailwind.config.js`์ `./resources/**/*.jsx` ๊ฒฝ๋ก ํฌํจ ์ฌ๋ถ) **ํ๋ก์ ํธ ๊ตฌ์กฐ ํ์ธ:** - `app/Http/Controllers/` : Laravel ์ปจํธ๋กค๋ฌ - `app/Models/` : Eloquent ๋ชจ๋ธ - `database/migrations/` : DB ๋ง์ด๊ทธ๋ ์ด์ - `resources/js/Pages/` : React ํ์ด์ง ์ปดํฌ๋ํธ (Inertia.js) - `resources/js/Components/` : ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ React ์ปดํฌ๋ํธ - `routes/web.php` : ์น ๋ผ์ฐํธ ์ ์ **๋ค์ ๋จ๊ณ ์ค๋น:** - ํฌํธํด๋ฆฌ์ค ์ํ(Portfolio)๊ณผ ๊ฒฌ์ ๋ฌธ์(Inquiry) ๋ชจ๋ธ ์ค๊ณ ์ค๋น - Breeze์ ๊ธฐ๋ณธ ์ธ์ฆ ๊ธฐ๋ฅ์ ์ ์งํ๋, ๋์์ด๋ ์ ์ฉ ๊ด๋ฆฌ ํ์ด์ง๋ก ํ์ฉ ์์ ๊ฐ๋ฐ ์๋ฒ ์คํ ํ `http://localhost:8000` ์ ์ํ์ฌ ๊ธฐ๋ณธ ํ๋ฉด ํ์ธ.
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel Breeze (React + Inertia) ํ๋ก์ ํธ๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์์ฑ๋๊ณ , ๊ฐ๋ฐ ์๋ฒ๊ฐ ์คํ๋ฉ๋๋ค. ๋ธ๋ผ์ฐ์ ์์ Welcome ํ์ด์ง ๋๋ Breeze ๊ธฐ๋ณธ ํ๋ฉด์ด ํ์๋๋ฉฐ, Tailwind CSS๊ฐ ์ ์ ์๋ํฉ๋๋ค. SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ํ์ผ์ด ์์ฑ๋๊ณ ๊ธฐ๋ณธ ๋ง์ด๊ทธ๋ ์ด์ (users, password_resets ๋ฑ)์ด ์๋ฃ๋ฉ๋๋ค.
**๋ฐ์ดํฐ๋ฒ ์ด์ค ์ค๊ณ ๋ฐ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ**
ํฌํธํด๋ฆฌ์ค ์ํ(Portfolio)๊ณผ ๊ฒฌ์ ๋ฌธ์(Inquiry) ํ
์ด๋ธ์ ์ค๊ณํฉ๋๋ค.
1. **Portfolio ๋ชจ๋ธ ๋ฐ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:**
```bash
php artisan make:model Portfolio -m
```
`database/migrations/xxxx_create_portfolios_table.php` ํ์ผ ํธ์ง:
```php
public function up()
{
Schema::create('portfolios', function (Blueprint $table) {
$table->id();
$table->string('title'); // ์ํ ์ ๋ชฉ
$table->text('description')->nullable(); // ์ํ ์ค๋ช
$table->string('category'); // ์นดํ
๊ณ ๋ฆฌ (๋ก๊ณ , ์น๋์์ธ, ํจํค์ง ๋ฑ)
$table->string('image_path'); // ์ด๋ฏธ์ง ์ ์ฅ ๊ฒฝ๋ก
$table->json('tools')->nullable(); // ์ฌ์ฉ ํด (JSON ๋ฐฐ์ด: ["Photoshop", "Illustrator"])
$table->integer('views')->default(0); // ์กฐํ์
$table->timestamps();
});
}
```
2. **Inquiry ๋ชจ๋ธ ๋ฐ ๋ง์ด๊ทธ๋ ์ด์
์์ฑ:**
```bash
php artisan make:model Inquiry -m
```
`database/migrations/xxxx_create_inquiries_table.php` ํ์ผ ํธ์ง:
```php
public function up()
{
Schema::create('inquiries', function (Blueprint $table) {
$table->id();
$table->string('service'); // ์์ฒญ ์๋น์ค (๋ก๊ณ ๋์์ธ, ๋ช
ํจ ๋์์ธ ๋ฑ)
$table->string('client_name'); // ์๋ขฐ์ธ ์ด๋ฆ
$table->string('client_email'); // ์๋ขฐ์ธ ์ด๋ฉ์ผ
$table->text('project_details'); // ํ๋ก์ ํธ ์์ธ ์ค๋ช
$table->string('budget_range')->nullable(); // ์์ฐ ๋ฒ์
$table->date('desired_date')->nullable(); // ํฌ๋ง ์๋ฃ ์ผ์
$table->string('reference_image')->nullable(); // ์ฐธ๊ณ ์ด๋ฏธ์ง ๊ฒฝ๋ก
$table->enum('status', ['pending', 'reviewed', 'in_progress', 'completed'])->default('pending'); // ๋ฌธ์ ์ํ
$table->timestamps();
});
}
```
3. **๋ง์ด๊ทธ๋ ์ด์
์คํ:**
```bash
php artisan migrate
```
4. **๋ชจ๋ธ์ Fillable ์์ฑ ์ถ๊ฐ:**
`app/Models/Portfolio.php`:
```php
protected $fillable = ['title', 'description', 'category', 'image_path', 'tools', 'views'];
protected $casts = ['tools' => 'array']; // JSON์ ๋ฐฐ์ด๋ก ์๋ ๋ณํ
```
`app/Models/Inquiry.php`:
```php
protected $fillable = ['service', 'client_name', 'client_email', 'project_details', 'budget_range', 'desired_date', 'reference_image', 'status'];
```
**๋ค์ ๋จ๊ณ ์ค๋น:**
- ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ ํ์ด์ง์ ์ปจํธ๋กค๋ฌ ๊ตฌํ
- ๋๋ฏธ ๋ฐ์ดํฐ ์๋ฉ (์ ํ์ฌํญ)
๊ธฐ๋ ๊ฒฐ๊ณผ: portfolios์ inquiries ํ ์ด๋ธ์ด SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์์ฑ๋ฉ๋๋ค. Portfolio์ Inquiry ๋ชจ๋ธ์ fillable ์์ฑ์ด ์ ์๋์ด ๋๋ ํ ๋น์ด ๊ฐ๋ฅํด์ง๋ฉฐ, tools ํ๋๋ JSON ๋ฐฐ์ด๋ก ์๋ ์บ์คํ ๋ฉ๋๋ค. ๋ง์ด๊ทธ๋ ์ด์ ์ด ์ค๋ฅ ์์ด ์๋ฃ๋ฉ๋๋ค.
**ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ ํ์ด์ง ๊ตฌํ (Laravel ์ปจํธ๋กค๋ฌ + React ์ปดํฌ๋ํธ)**
1. **PortfolioController ์์ฑ:**
```bash
php artisan make:controller PortfolioController
```
`app/Http/Controllers/PortfolioController.php`:
```php
<?php
namespace App\Http\Controllers;
use App\Models\Portfolio;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PortfolioController extends Controller
{
public function index(Request $request)
{
$category = $request->query('category');
$portfolios = Portfolio::query()
->when($category, function ($query, $category) {
return $query->where('category', $category);
})
->latest()
->paginate(9);
$categories = Portfolio::distinct()->pluck('category');
return Inertia::render('Portfolio/Index', [
'portfolios' => $portfolios,
'categories' => $categories,
'selectedCategory' => $category,
]);
}
public function show($id)
{
$portfolio = Portfolio::findOrFail($id);
$portfolio->increment('views'); // ์กฐํ์ ์ฆ๊ฐ
$prev = Portfolio::where('id', '<', $id)->orderBy('id', 'desc')->first();
$next = Portfolio::where('id', '>', $id)->orderBy('id', 'asc')->first();
return Inertia::render('Portfolio/Show', [
'portfolio' => $portfolio,
'prev' => $prev,
'next' => $next,
]);
}
}
```
2. **๋ผ์ฐํธ ์ถ๊ฐ (`routes/web.php`):**
```php
use App\Http\Controllers\PortfolioController;
Route::get('/', [PortfolioController::class, 'index'])->name('portfolio.index');
Route::get('/portfolio/{id}', [PortfolioController::class, 'show'])->name('portfolio.show');
```
3. **React ํ์ด์ง ์ปดํฌ๋ํธ ์์ฑ:**
`resources/js/Pages/Portfolio/Index.jsx`:
```jsx
import { Head, Link } from '@inertiajs/react';
export default function Index({ portfolios, categories, selectedCategory }) {
return (
<>
<Head title="ํฌํธํด๋ฆฌ์ค" />
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4">
<h1 className="text-3xl font-bold text-gray-900">๋์์ด๋ ํฌํธํด๋ฆฌ์ค</h1>
</div>
</header>
<main className="max-w-7xl mx-auto py-8 px-4">
{/* ์นดํ
๊ณ ๋ฆฌ ํํฐ */}
<div className="flex gap-2 mb-6 flex-wrap">
<Link
href="/"
className={`px-4 py-2 rounded-full ${
!selectedCategory
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
>
์ ์ฒด
</Link>
{categories.map((cat) => (
<Link
key={cat}
href={`/?category=${cat}`}
className={`px-4 py-2 rounded-full ${
selectedCategory === cat
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
>
{cat}
</Link>
))}
</div>
{/* ์ํ ๊ทธ๋ฆฌ๋ */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{portfolios.data.map((item) => (
<Link
key={item.id}
href={`/portfolio/${item.id}`}
className="bg-white rounded-lg shadow overflow-hidden hover:shadow-lg transition"
>
<img
src={`/storage/${item.image_path}`}
alt={item.title}
className="w-full h-64 object-cover"
/>
<div className="p-4">
<h3 className="font-semibold text-lg mb-2">{item.title}</h3>
<p className="text-sm text-gray-600 mb-2">{item.category}</p>
<p className="text-xs text-gray-500">์กฐํ์: {item.views}</p>
</div>
</Link>
))}
</div>
{/* ํ์ด์ง๋ค์ด์
*/}
<div className="mt-8 flex justify-center gap-2">
{portfolios.links.map((link, index) => (
<Link
key={index}
href={link.url || '#'}
className={`px-4 py-2 rounded ${
link.active
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
))}
</div>
</main>
</div>
</>
);
}
```
**๋ค์ ๋จ๊ณ ์ค๋น:**
- ์ํ ์์ธ ํ์ด์ง ๊ตฌํ
- ๋๋ฏธ ๋ฐ์ดํฐ ์ถ๊ฐํ์ฌ ๊ฐค๋ฌ๋ฆฌ ํ
์คํธ
๊ธฐ๋ ๊ฒฐ๊ณผ: ํฌํธํด๋ฆฌ์ค ๊ฐค๋ฌ๋ฆฌ ํ์ด์ง๊ฐ ํ์๋ฉ๋๋ค. ์นดํ ๊ณ ๋ฆฌ ํํฐ๋ฅผ ํด๋ฆญํ๋ฉด ํด๋น ์นดํ ๊ณ ๋ฆฌ์ ์ํ๋ง ํํฐ๋ง๋์ด ๋ณด์ ๋๋ค. ํ์ด์ง๋ค์ด์ ์ด ์๋ํ๋ฉฐ, ์ํ ์นด๋๋ฅผ ํด๋ฆญํ๋ฉด ์์ธ ํ์ด์ง๋ก ์ด๋ํฉ๋๋ค (์์ง ๊ตฌํ ์ ์ด๋ฏ๋ก 404 ๋๋ ๋น ํ์ด์ง). Tailwind CSS ์คํ์ผ์ด ์ ์ฉ๋์ด ๊น๋ํ ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ด ํ์๋ฉ๋๋ค.
**์ํ ์์ธ ํ์ด์ง ๊ตฌํ (๋ผ์ดํธ๋ฐ์ค ๋ชจ๋ฌ, ์ด์ /๋ค์ ๋ค๋น๊ฒ์ด์
)**
`resources/js/Pages/Portfolio/Show.jsx`:
```jsx
import { Head, Link } from '@inertiajs/react';
import { useState } from 'react';
export default function Show({ portfolio, prev, next }) {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<Head title={portfolio.title} />
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4">
<Link href="/" className="text-blue-600 hover:underline">
โ ๊ฐค๋ฌ๋ฆฌ๋ก ๋์๊ฐ๊ธฐ
</Link>
</div>
</header>
<main className="max-w-4xl mx-auto py-8 px-4">
{/* ์ํ ์ด๋ฏธ์ง */}
<div className="bg-white rounded-lg shadow overflow-hidden mb-6">
<img
src={`/storage/${portfolio.image_path}`}
alt={portfolio.title}
className="w-full cursor-pointer"
onClick={() => setIsModalOpen(true)}
/>
</div>
{/* ์ํ ์ ๋ณด */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h1 className="text-3xl font-bold mb-2">{portfolio.title}</h1>
<p className="text-gray-600 mb-4">{portfolio.category}</p>
<p className="text-gray-700 mb-4">{portfolio.description}</p>
{portfolio.tools && portfolio.tools.length > 0 && (
<div className="mb-4">
<h3 className="font-semibold mb-2">์ฌ์ฉ ํด:</h3>
<div className="flex gap-2 flex-wrap">
{portfolio.tools.map((tool, index) => (
<span
key={index}
className="px-3 py-1 bg-gray-200 text-gray-700 rounded-full text-sm"
>
{tool}
</span>
))}
</div>
</div>
)}
<p className="text-sm text-gray-500">์กฐํ์: {portfolio.views}</p>
</div>
{/* ์ด์ /๋ค์ ๋ค๋น๊ฒ์ด์
*/}
<div className="flex justify-between">
{prev ? (
<Link
href={`/portfolio/${prev.id}`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
โ ์ด์ ์ํ
</Link>
) : (
<div></div>
)}
{next ? (
<Link
href={`/portfolio/${next.id}`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
๋ค์ ์ํ โ
</Link>
) : (
<div></div>
)}
</div>
</main>
</div>
{/* ๋ผ์ดํธ๋ฐ์ค ๋ชจ๋ฌ */}
{isModalOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"
onClick={() => setIsModalOpen(false)}
>
<img
src={`/storage/${portfolio.image_path}`}
alt={portfolio.title}
className="max-w-full max-h-full"
/>
<button
className="absolute top-4 right-4 text-white text-4xl"
onClick={() => setIsModalOpen(false)}
>
ร
</button>
</div>
)}
</>
);
}
```
**ํ
์คํธ์ฉ ๋๋ฏธ ๋ฐ์ดํฐ ์ถ๊ฐ:**
`php artisan tinker` ์คํ ํ:
```php
App\Models\Portfolio::create([
'title' => '๋ฏธ๋๋ฉ ๋ก๊ณ ๋์์ธ',
'description' => '๊น๋ํ๊ณ ํ๋์ ์ธ ๋ก๊ณ ์์
',
'category' => '๋ก๊ณ ',
'image_path' => 'portfolios/sample1.jpg', // public/storage/portfolios/sample1.jpg ์ค๋น ํ์
'tools' => ['Illustrator', 'Photoshop'],
]);
App\Models\Portfolio::create([
'title' => '๋ฐ์ํ ์น์ฌ์ดํธ ๋์์ธ',
'description' => '๋ชจ๋ฐ์ผ ์นํ์ UI/UX',
'category' => '์น๋์์ธ',
'image_path' => 'portfolios/sample2.jpg',
'tools' => ['Figma', 'Adobe XD'],
]);
```
*์ฐธ๊ณ : ์ค์ ์ด๋ฏธ์ง๋ฅผ `public/storage/portfolios/` ํด๋์ ๋ฐฐ์นํ๊ฑฐ๋, placeholder ์ด๋ฏธ์ง ์๋น์ค ์ฌ์ฉ (์: `https://via.placeholder.com/800x600`)*
**๋ค์ ๋จ๊ณ ์ค๋น:**
- ๊ฒฌ์ ๋ฌธ์ ํผ ํ์ด์ง ๊ตฌํ
๊ธฐ๋ ๊ฒฐ๊ณผ: ์ํ ์์ธ ํ์ด์ง๊ฐ ํ์๋๋ฉฐ, ์ํ ์ด๋ฏธ์ง ํด๋ฆญ ์ ๋ผ์ดํธ๋ฐ์ค ๋ชจ๋ฌ์ด ์ด๋ฆฝ๋๋ค. ์ํ ์ ๋ชฉ, ์ค๋ช , ์นดํ ๊ณ ๋ฆฌ, ์ฌ์ฉ ํด ํ๊ทธ, ์กฐํ์๊ฐ ํ์๋ฉ๋๋ค. ์ด์ /๋ค์ ์ํ ๋ฒํผ์ ํด๋ฆญํ๋ฉด ํด๋น ์ํ ํ์ด์ง๋ก ์ด๋ํฉ๋๋ค. ์กฐํ์๊ฐ ํ์ด์ง ์ ์ ์๋ง๋ค 1์ฉ ์ฆ๊ฐํฉ๋๋ค.
**๊ฒฌ์ ๋ฌธ์ ํผ ํ์ด์ง ๊ตฌํ (์๋น์ค ์ ํ, ํ์ผ ์
๋ก๋, ์ ํจ์ฑ ๊ฒ์ฌ)**
1. **InquiryController ์์ฑ:**
```bash
php artisan make:controller InquiryController
```
`app/Http/Controllers/InquiryController.php`:
```php
<?php
namespace App\Http\Controllers;
use App\Models\Inquiry;
use Illuminate\Http\Request;
use Inertia\Inertia;
class InquiryController extends Controller
{
public function create()
{
$services = ['๋ก๊ณ ๋์์ธ', '๋ช
ํจ ๋์์ธ', '์น ๋ฐฐ๋', 'ํจํค์ง ๋์์ธ', '๋ธ๋๋ ์์ด๋ดํฐํฐ'];
return Inertia::render('Inquiry/Create', [
'services' => $services,
]);
}
public function store(Request $request)
{
$validated = $request->validate([
'service' => 'required|string',
'client_name' => 'required|string|max:255',
'client_email' => 'required|email',
'project_details' => 'required|string|min:20',
'budget_range' => 'nullable|string',
'desired_date' => 'nullable|date|after:today',
'reference_image' => 'nullable|image|max:5120', // ์ต๋ 5MB
]);
if ($request->hasFile('reference_image')) {
$path = $request->file('reference_image')->store('inquiries', 'public');
$validated['reference_image'] = $path;
}
Inquiry::create($validated);
return redirect()->route('inquiry.success');
}
public function success()
{
return Inertia::render('Inquiry/Success');
}
}
```
2. **๋ผ์ฐํธ ์ถ๊ฐ (`routes/web.php`):**
```php
use App\Http\Controllers\InquiryController;
Route::get('/inquiry', [InquiryController::class, 'create'])->name('inquiry.create');
Route::post('/inquiry', [InquiryController::class, 'store'])->name('inquiry.store');
Route::get('/inquiry/success', [InquiryController::class, 'success'])->name('inquiry.success');
```
3. **React ํผ ์ปดํฌ๋ํธ ์์ฑ:**
`resources/js/Pages/Inquiry/Create.jsx`:
```jsx
import { Head, useForm } from '@inertiajs/react';
export default function Create({ services }) {
const { data, setData, post, processing, errors } = useForm({
service: '',
client_name: '',
client_email: '',
project_details: '',
budget_range: '',
desired_date: '',
reference_image: null,
});
function submit(e) {
e.preventDefault();
post('/inquiry');
}
return (
<>
<Head title="๊ฒฌ์ ๋ฌธ์" />
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4">
<h1 className="text-3xl font-bold text-gray-900">๊ฒฌ์ ๋ฌธ์</h1>
</div>
</header>
<main className="max-w-2xl mx-auto py-8 px-4">
<form onSubmit={submit} className="bg-white rounded-lg shadow p-6 space-y-4">
{/* ์๋น์ค ์ ํ */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
์์ฒญ ์๋น์ค *
</label>
<select
value={data.service}
onChange={(e) => setData('service', e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="">์ ํํ์ธ์</option>
{services.map((service) => (
<option key={service} value={service}>
{service}
</option>
))}
</select>
{errors.service && (
<p className="text-red-600 text-sm mt-1">{errors.service}</p>
)}
</div>
{/* ์ด๋ฆ */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
์ด๋ฆ *
</label>
<input
type="text"
value={data.client_name}
onChange={(e) => setData('client_name', e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
/>
{errors.client_name && (
<p className="text-red-600 text-sm mt-1">{errors.client_name}</p>
)}
</div>
{/* ์ด๋ฉ์ผ */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
์ด๋ฉ์ผ *
</label>
<input
type="email"
value={data.client_email}
onChange={(e) => setData('client_email', e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
/>
{errors.client_email && (
<p className="text-red-600 text-sm mt-1">{errors.client_email}</p>
)}
</div>
{/* ํ๋ก์ ํธ ์์ธ */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
ํ๋ก์ ํธ ์์ธ ์ค๋ช
* (์ต์ 20์)
</label>
<textarea
value={data.project_details}
onChange={(e) => setData('project_details', e.target.value)}
rows="5"
className="w-full border border-gray-300 rounded px-3 py-2"
/>
{errors.project_details && (
<p className="text-red-600 text-sm mt-1">{errors.project_details}</p>
)}
</div>
{/* ์์ฐ ๋ฒ์ */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
์์ฐ ๋ฒ์ (์ ํ)
</label>
<select
value={data.budget_range}
onChange={(e) => setData('budget_range', e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="">์ ํํ์ธ์</option>
<option value="50๋ง์ ์ดํ">50๋ง์ ์ดํ</option>
<option value="50๋ง์ ~ 100๋ง์">50๋ง์ ~ 100๋ง์</option>
<option value="100๋ง์ ~ 200๋ง์">100๋ง์ ~ 200๋ง์</option>
<option value="200๋ง์ ์ด์">200๋ง์ ์ด์</option>
</select>
</div>
{/* ํฌ๋ง ์๋ฃ ์ผ์ */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
ํฌ๋ง ์๋ฃ ์ผ์ (์ ํ)
</label>
<input
type="date"
value={data.desired_date}
onChange={(e) => setData('desired_date', e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
/>
{errors.desired_date && (
<p className="text-red-600 text-sm mt-1">{errors.desired_date}</p>
)}
</div>
{/* ์ฐธ๊ณ ์ด๋ฏธ์ง */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
์ฐธ๊ณ ์ด๋ฏธ์ง (์ ํ, ์ต๋ 5MB)
</label>
<input
type="file"
accept="image/*"
onChange={(e) => setData('reference_image', e.target.files[0])}
className="w-full border border-gray-300 rounded px-3 py-2"
/>
{errors.reference_image && (
<p className="text-red-600 text-sm mt-1">{errors.reference_image}</p>
)}
</div>
{/* ์ ์ถ ๋ฒํผ */}
<button
type="submit"
disabled={processing}
className="w-full bg-blue-600 text-white py-3 rounded hover:bg-blue-700 disabled:bg-gray-400"
>
{processing ? '์ ์ก ์ค...' : '๊ฒฌ์ ๋ฌธ์ ๋ณด๋ด๊ธฐ'}
</button>
</form>
</main>
</div>
</>
);
}
```
4. **์ฑ๊ณต ํ์ด์ง ์์ฑ:**
`resources/js/Pages/Inquiry/Success.jsx`:
```jsx
import { Head, Link } from '@inertiajs/react';
export default function Success() {
return (
<>
<Head title="๋ฌธ์ ์๋ฃ" />
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="bg-white rounded-lg shadow p-8 text-center max-w-md">
<div className="text-6xl mb-4">โ
</div>
<h1 className="text-2xl font-bold mb-4">๋ฌธ์๊ฐ ์ ์๋์์ต๋๋ค!</h1>
<p className="text-gray-600 mb-6">
๋น ๋ฅธ ์์ผ ๋ด์ ์ด๋ฉ์ผ๋ก ๋ต๋ณ ๋๋ฆฌ๊ฒ ์ต๋๋ค.
</p>
<Link
href="/"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded hover:bg-blue-700"
>
๊ฐค๋ฌ๋ฆฌ๋ก ๋์๊ฐ๊ธฐ
</Link>
</div>
</div>
</>
);
}
```
**๋ค์ ๋จ๊ณ ์ค๋น:**
- ๋ค๋น๊ฒ์ด์
๋ฉ๋ด์ ๊ฒฌ์ ๋ฌธ์ ๋งํฌ ์ถ๊ฐ
- ๋์์ด๋์ฉ ๋ฌธ์ ๊ด๋ฆฌ ํ์ด์ง ๊ตฌํ
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ฒฌ์ ๋ฌธ์ ํผ ํ์ด์ง๊ฐ ํ์๋ฉ๋๋ค. ๋ชจ๋ ํ๋์ ๋ํ ์ ํจ์ฑ ๊ฒ์ฌ๊ฐ ์๋ํ๋ฉฐ, ํ์ ํญ๋ชฉ์ ์ ๋ ฅํ์ง ์๊ฑฐ๋ ํ์์ด ์๋ชป๋ ๊ฒฝ์ฐ ์๋ฌ ๋ฉ์์ง๊ฐ ํ์๋ฉ๋๋ค. ํ์ผ ์ ๋ก๋ ์ ์ด๋ฏธ์ง ํ์ผ๋ง ์ ํ ๊ฐ๋ฅํ๋ฉฐ, 5MB ์ด์ ํ์ผ์ ๊ฑฐ๋ถ๋ฉ๋๋ค. ํผ ์ ์ถ ํ ์ฑ๊ณต ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ๋๋ฉฐ, ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ฌธ์ ๋ด์ญ์ด ์ ์ฅ๋ฉ๋๋ค.
**๋์์ด๋์ฉ ๋ฌธ์ ๊ด๋ฆฌ ํ์ด์ง ๊ตฌํ (์ธ์ฆ ํ์, ์ํ ๋ณ๊ฒฝ)**
1. **InquiryController์ ๊ด๋ฆฌ ๋ฉ์๋ ์ถ๊ฐ:**
`app/Http/Controllers/InquiryController.php`์ ์ถ๊ฐ:
```php
public function manage()
{
$inquiries = Inquiry::latest()->paginate(10);
return Inertia::render('Inquiry/Manage', [
'inquiries' => $inquiries,
]);
}
public function updateStatus(Request $request, $id)
{
$validated = $request->validate([
'status' => 'required|in:pending,reviewed,in_progress,completed',
]);
$inquiry = Inquiry::findOrFail($id);
$inquiry->update($validated);
return back();
}
```
2. **๋ผ์ฐํธ ์ถ๊ฐ (์ธ์ฆ ํ์):**
`routes/web.php`:
```php
Route::middleware('auth')->group(function () {
Route::get('/manage/inquiries', [InquiryController::class, 'manage'])->name('inquiry.manage');
Route::patch('/manage/inquiries/{id}/status', [InquiryController::class, 'updateStatus'])->name('inquiry.updateStatus');
});
```
3. **React ๊ด๋ฆฌ ํ์ด์ง ์์ฑ:**
`resources/js/Pages/Inquiry/Manage.jsx`:
```jsx
import { Head, Link, router } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
export default function Manage({ auth, inquiries }) {
const statusColors = {
pending: 'bg-yellow-100 text-yellow-800',
reviewed: 'bg-blue-100 text-blue-800',
in_progress: 'bg-purple-100 text-purple-800',
completed: 'bg-green-100 text-green-800',
};
const statusLabels = {
pending: '๊ฒํ ๋๊ธฐ',
reviewed: '๊ฒํ ์๋ฃ',
in_progress: '์งํ ์ค',
completed: '์๋ฃ',
};
function changeStatus(id, newStatus) {
router.patch(`/manage/inquiries/${id}/status`, { status: newStatus });
}
return (
<AuthenticatedLayout
user={auth.user}
header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">๋ฌธ์ ๊ด๋ฆฌ</h2>}
>
<Head title="๋ฌธ์ ๊ด๋ฆฌ" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div className="p-6">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
์๋น์ค
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
์๋ขฐ์ธ
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
์ด๋ฉ์ผ
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
์ํ
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
์ ์์ผ
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
์ก์
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{inquiries.data.map((inquiry) => (
<tr key={inquiry.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{inquiry.service}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{inquiry.client_name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{inquiry.client_email}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
statusColors[inquiry.status]
}`}
>
{statusLabels[inquiry.status]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(inquiry.created_at).toLocaleDateString('ko-KR')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<select
value={inquiry.status}
onChange={(e) => changeStatus(inquiry.id, e.target.value)}
className="border border-gray-300 rounded px-2 py-1 text-xs"
>
<option value="pending">๊ฒํ ๋๊ธฐ</option>
<option value="reviewed">๊ฒํ ์๋ฃ</option>
<option value="in_progress">์งํ ์ค</option>
<option value="completed">์๋ฃ</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* ํ์ด์ง๋ค์ด์
*/}
<div className="mt-4 flex justify-center gap-2">
{inquiries.links.map((link, index) => (
<Link
key={index}
href={link.url || '#'}
className={`px-4 py-2 rounded ${
link.active
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
))}
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
4. **๋ค๋น๊ฒ์ด์
๋ฉ๋ด์ ๋งํฌ ์ถ๊ฐ:**
`resources/js/Layouts/AuthenticatedLayout.jsx`์ ๋ค๋น๊ฒ์ด์
๋ถ๋ถ์ ์ถ๊ฐ:
```jsx
<NavLink href={route('inquiry.manage')} active={route().current('inquiry.manage')}>
๋ฌธ์ ๊ด๋ฆฌ
</NavLink>
```
5. **๊ณต๊ฐ ํ์ด์ง์ ๊ฒฌ์ ๋ฌธ์ ๋ฒํผ ์ถ๊ฐ:**
`resources/js/Pages/Portfolio/Index.jsx`์ ํค๋์ ๋ฒํผ ์ถ๊ฐ:
```jsx
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900">๋์์ด๋ ํฌํธํด๋ฆฌ์ค</h1>
<Link
href="/inquiry"
className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
>
๊ฒฌ์ ๋ฌธ์ํ๊ธฐ
</Link>
</div>
</header>
```
**์ต์ข
ํ
์คํธ:**
1. ํ์๊ฐ์
/๋ก๊ทธ์ธ (Breeze ๊ธฐ๋ณธ ๊ธฐ๋ฅ)
2. ๊ฐค๋ฌ๋ฆฌ์์ ์ํ ํ์ธ
3. ๊ฒฌ์ ๋ฌธ์ ํผ ์ ์ถ
4. ๋ก๊ทธ์ธ ํ ๋ฌธ์ ๊ด๋ฆฌ ํ์ด์ง์์ ์ํ ๋ณ๊ฒฝ
**ํ๋ก์ ํธ ์์ฑ!**
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋์์ด๋๊ฐ ๋ก๊ทธ์ธ ํ ๋ฌธ์ ๊ด๋ฆฌ ํ์ด์ง์ ์ ์ํ๋ฉด, ๋ชจ๋ ๊ฒฌ์ ๋ฌธ์ ๋ชฉ๋ก์ด ํ ์ด๋ธ ํํ๋ก ํ์๋ฉ๋๋ค. ๊ฐ ๋ฌธ์์ ์ํ๋ฅผ ๋๋กญ๋ค์ด์ผ๋ก ๋ณ๊ฒฝํ ์ ์์ผ๋ฉฐ, ๋ณ๊ฒฝ ์ฆ์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ๋ฐ์๋ฉ๋๋ค. ๊ณต๊ฐ ๊ฐค๋ฌ๋ฆฌ ํ์ด์ง ์๋จ์ '๊ฒฌ์ ๋ฌธ์ํ๊ธฐ' ๋ฒํผ์ด ์ถ๊ฐ๋์ด ์ฌ์ฉ์๊ฐ ์ฝ๊ฒ ํผ์ผ๋ก ์ด๋ํ ์ ์์ต๋๋ค. ์ ์ฒด ํ๋ซํผ์ด ๋ฐ์ํ์ผ๋ก ์๋ํ๋ฉฐ, Laravel + Inertia.js + React์ ๋ชจ๋ ธ๋ฆฌ์ค ํ์คํ ๊ตฌ์กฐ๊ฐ ์์ฑ๋ฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!