๐ช ์์ปค์ค ๊ณต์ฐ ์์ฆ ํจ์ค ํ๋งค ํ๋ซํผ - ํ์ฐจ๋ณ ํฐ์ผ ๊ตฌ๋งค & ๊ด๋ ํ์คํ ๋ฆฌ
circus-season-pass-platform
๋ฌธ์ ์ค๋ช
๐ช ์์ปค์ค ๊ณต์ฐ ์์ฆ ํจ์ค ํ๋งค ํ๋ซํผ
์์ปค์ค๋จ์ด๋ ๊ณต์ฐํ์ ์ํ ์์ฆ ํจ์ค ํ๋งค ํ๋ซํผ์ ๋๋ค. ๊ณ ๊ฐ์ด ์์ฆ๋ณ๋ก ์ฌ๋ฌ ๊ณต์ฐ ํ์ฐจ๋ฅผ ๋ฌถ์์ผ๋ก ๊ตฌ๋งคํ๊ณ , ๊ด๋ํ ๊ณต์ฐ์ ์ฒดํฌํ๋ฉฐ ํ์คํ ๋ฆฌ๋ฅผ ๊ด๋ฆฌํ ์ ์์ต๋๋ค.
ํ์ ๊ธฐ๋ฅ
- ์์ฆ ํจ์ค ๋ชฉ๋ก: ์์ฆ๋ณ ํจ์ค ์ํ ์นด๋ (์: ๋ด ์์ฆ 5ํ๊ถ, ์ฌ๋ฆ ์์ฆ 10ํ๊ถ)
- ํจ์ค ์์ธ ํ์ด์ง: ํฌํจ๋ ๊ณต์ฐ ํ์ฐจ ๋ชฉ๋ก, ๊ฐ๊ฒฉ, ํํ ์ ๋ณด
- ๊ตฌ๋งค ํผ: ํจ์ค ์ ํ โ ๊ณ ๊ฐ ์ ๋ณด ์ ๋ ฅ โ ๊ตฌ๋งค ์๋ฃ (๊ฒฐ์ ์ฐ๋ ์์ด ์ฃผ๋ฌธ ์ ์๋ง)
- ๋ด ํจ์ค ๋์๋ณด๋: ๊ตฌ๋งคํ ํจ์ค ๋ชฉ๋ก, ์ฌ์ฉ ๊ฐ๋ฅ ํ์, ๊ด๋ ์๋ฃ ์ฒดํฌ
- ๊ด๋ ํ์คํ ๋ฆฌ: ์ด๋ค ๊ณต์ฐ์ ์ธ์ ๋ดค๋์ง ํ์๋ผ์ธ์ผ๋ก ํ์ธ
- ํ์ฐจ ์ฒดํฌ์ธ: QR ์ฝ๋ ๋๋ ํฐ์ผ ๋ฒํธ๋ก ๊ด๋ ์๋ฃ ์ฒ๋ฆฌ
๋ณด๋์ค ๊ธฐ๋ฅ
- ํจ์ค ์ฌ์ฉ๋ฅ ํ๋ก๊ทธ๋ ์ค ๋ฐ (5ํ ์ค 3ํ ์ฌ์ฉ ๋ฑ)
- ์์ฆ๋ณ ๊ณต์ฐ ํฌ์คํฐ ๊ฐค๋ฌ๋ฆฌ
- ๊ด๋ ํ ๊ฐ๋จํ ๋ณ์ /ํ๊ธฐ ๋จ๊ธฐ๊ธฐ
- ํจ์ค ์ ๋ฌผํ๊ธฐ (์น๊ตฌ ์ด๋ฉ์ผ๋ก ์ ์ก)
- ๊ด๋ฆฌ์ ํ์ด์ง: ํจ์ค ํ๋งค ํต๊ณ, ํ์ฐจ๋ณ ์ ์ฅ ํํฉ
๊ธฐ์ ์คํ
- ๋ฐฑ์๋: Laravel 13.x (๋ชจ๋ธ, ๋ง์ด๊ทธ๋ ์ด์ , ์ปจํธ๋กค๋ฌ, Eloquent ๊ด๊ณ)
- ํ๋ก ํธ์๋: React 19 (์ปดํฌ๋ํธ, useState, ํผ ํธ๋ค๋ง)
- ํ์คํ ์ฐ๊ฒฐ: Inertia.js (Laravel ๋ผ์ฐํธ โ React ํ์ด์ง ์ปดํฌ๋ํธ)
- ์คํ์ผ๋ง: Tailwind CSS
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (๊ฐ๋ฐ์ฉ, ๋ฐฐํฌ ์ MySQL/PostgreSQL)
๋ฐ์ดํฐ ๋ชจ๋ธ ์์
- SeasonPass: ์์ฆ ํจ์ค ์ํ (์ด๋ฆ, ์ค๋ช , ๊ฐ๊ฒฉ, ํฌํจ ํ์ฐจ ์, ์ ํจ๊ธฐ๊ฐ)
- Purchase: ๊ตฌ๋งค ๋ด์ญ (๊ณ ๊ฐ ์ ๋ณด, ํจ์ค ID, ๊ตฌ๋งค ๋ ์ง, ๋จ์ ํ์ฐจ ์)
- Performance: ๊ณต์ฐ ํ์ฐจ (์ ๋ชฉ, ๋ ์ง, ์ฅ์, ํฌ์คํฐ ์ด๋ฏธ์ง)
- Checkin: ๊ด๋ ๊ธฐ๋ก (๊ตฌ๋งค ID, ๊ณต์ฐ ID, ์ฒดํฌ์ธ ์๊ฐ, ๋ณ์ /ํ๊ธฐ)
์ด ํ๋ก์ ํธ๋ฅผ ํตํด Laravel์ Eloquent ๊ด๊ณ(1:N, N:M), Inertia.js์ ํผ ํธ๋ค๋ง, React์ ์กฐ๊ฑด๋ถ ๋ ๋๋ง๊ณผ ์ํ ๊ด๋ฆฌ๋ฅผ ์ตํ ์ ์์ต๋๋ค.
์ ์ฝ ์กฐ๊ฑด
- โข Laravel 13.x + Inertia.js + React 19 ๋ชจ๋ ธ๋ฆฌ์ค ํ์คํ ๊ตฌ์กฐ (๋ณ๋ API ์์)
- โข Laravel Breeze (React + Inertia) starter kit ๋๋ `npx create laravel` ์ฌ์ฉ
- โข Inertia::render()๋ก Laravel ์ปจํธ๋กค๋ฌ์์ React ์ปดํฌ๋ํธ์ ๋ฐ์ดํฐ ์ ๋ฌ
- โข SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฌ์ฉ (๋ง์ด๊ทธ๋ ์ด์ ํ์)
- โข Eloquent ๊ด๊ณ ํ์ฉ: SeasonPass hasMany Purchase, Purchase belongsTo SeasonPass, Purchase hasMany Checkin, Checkin belongsTo Performance
- โข Tailwind CSS๋ก ๋ฐ์ํ UI ๊ตฌํ
- โข ๊ฒฐ์ ์ฐ๋ ์์ (์ฃผ๋ฌธ ์ ์๋ง, ์ค์ ๊ฒฐ์ ๋ ๋ณด๋์ค)
- โข ์ฌ์ฉ์ ์ธ์ฆ ์์คํ ํฌํจ (Laravel Breeze ๊ธฐ๋ณธ ์ ๊ณต)
- โข ํผ ์ ํจ์ฑ ๊ฒ์ฌ: Laravel Request Validation + React ์๋ฌ ํ์
- โข ์ด๋ฏธ์ง ์ ๋ก๋: Laravel Storage (public ๋์คํฌ, ํฌ์คํฐ ์ด๋ฏธ์ง)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
mkdir circus-season-pass-platform && cd circus-season-pass-platform ์ด ํด๋์์ Laravel 13.x + Inertia.js + React 19 ํ๋ก์ ํธ๋ฅผ ์์ํฉ๋๋ค. ## ํ๋ก์ ํธ ์ด๊ธฐ ์ค์ 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 build ``` - `.env` ํ์ผ์์ `DB_CONNECTION=sqlite`๋ก ๋ณ๊ฒฝํ๊ณ , `database/database.sqlite` ํ์ผ ์์ฑ - `php artisan migrate` ์คํํ์ฌ ๊ธฐ๋ณธ ์ธ์ฆ ํ ์ด๋ธ ์์ฑ - `php artisan serve`๋ก ๊ฐ๋ฐ ์๋ฒ ์คํ (http://localhost:8000) - ๋ณ๋ ํฐ๋ฏธ๋์์ `npm run dev` ์คํ (Vite HMR) ํ๋ก์ ํธ๊ฐ ์ ์์ ์ผ๋ก ์คํ๋๊ณ , ํ์๊ฐ์ /๋ก๊ทธ์ธ ํ์ด์ง๊ฐ ํ์๋๋์ง ํ์ธํ์ธ์. ### ๊ธฐ๋ ๊ฒฐ๊ณผ - Laravel Breeze ๊ธฐ๋ณธ ์ธ์ฆ ์์คํ ๋์ - React ์ปดํฌ๋ํธ๊ฐ Inertia.js๋ฅผ ํตํด ๋ ๋๋ง๋จ - Tailwind CSS ์คํ์ผ ์ ์ฉ๋จ
๊ธฐ๋ ๊ฒฐ๊ณผ: Laravel 13.x + Breeze + React + Inertia.js ํ๋ก์ ํธ๊ฐ ์์ฑ๋๊ณ , ๊ธฐ๋ณธ ์ธ์ฆ ํ์ด์ง(๋ก๊ทธ์ธ/ํ์๊ฐ์ )๊ฐ ์ ์ ๋์ํฉ๋๋ค. ๊ฐ๋ฐ ์๋ฒ์ Vite๊ฐ ์คํ ์ค์ด๋ฉฐ, SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค๊ฐ ์ฐ๊ฒฐ๋์ด ์์ต๋๋ค.
## ๋ฐ์ดํฐ๋ฒ ์ด์ค ๋ง์ด๊ทธ๋ ์ด์ ์์ฑ ๋ค์ 4๊ฐ ํ ์ด๋ธ์ ๋ง์ด๊ทธ๋ ์ด์ ์ ์์ฑํ์ธ์: ### 1. season_passes ํ ์ด๋ธ ```bash php artisan make:migration create_season_passes_table ``` ์ปฌ๋ผ: - `id` (primary key) - `name` (string, ์์ฆ ํจ์ค ์ด๋ฆ, ์: "๋ด ์์ฆ 5ํ๊ถ") - `description` (text, ์ค๋ช ) - `price` (decimal, ๊ฐ๊ฒฉ) - `included_performances` (integer, ํฌํจ ํ์ฐจ ์) - `valid_from` (date, ์ ํจ๊ธฐ๊ฐ ์์) - `valid_until` (date, ์ ํจ๊ธฐ๊ฐ ์ข ๋ฃ) - `poster_image` (string, nullable, ํฌ์คํฐ ์ด๋ฏธ์ง ๊ฒฝ๋ก) - `timestamps` ### 2. purchases ํ ์ด๋ธ ```bash php artisan make:migration create_purchases_table ``` ์ปฌ๋ผ: - `id` (primary key) - `user_id` (foreignId, users ํ ์ด๋ธ ์ฐธ์กฐ, onDelete cascade) - `season_pass_id` (foreignId, season_passes ํ ์ด๋ธ ์ฐธ์กฐ, onDelete cascade) - `customer_name` (string) - `customer_email` (string) - `customer_phone` (string) - `purchased_at` (timestamp, ๊ตฌ๋งค ๋ ์ง) - `remaining_uses` (integer, ๋จ์ ์ฌ์ฉ ํ์, ๊ธฐ๋ณธ๊ฐ์ season_pass์ included_performances) - `timestamps` ### 3. performances ํ ์ด๋ธ ```bash php artisan make:migration create_performances_table ``` ์ปฌ๋ผ: - `id` (primary key) - `title` (string, ๊ณต์ฐ ์ ๋ชฉ) - `description` (text) - `performance_date` (dateTime, ๊ณต์ฐ ๋ ์ง/์๊ฐ) - `venue` (string, ๊ณต์ฐ ์ฅ์) - `poster_image` (string, nullable) - `timestamps` ### 4. checkins ํ ์ด๋ธ ```bash php artisan make:migration create_checkins_table ``` ์ปฌ๋ผ: - `id` (primary key) - `purchase_id` (foreignId, purchases ํ ์ด๋ธ ์ฐธ์กฐ, onDelete cascade) - `performance_id` (foreignId, performances ํ ์ด๋ธ ์ฐธ์กฐ, onDelete cascade) - `checked_in_at` (timestamp, ์ฒดํฌ์ธ ์๊ฐ) - `rating` (integer, nullable, 1~5 ๋ณ์ ) - `review` (text, nullable, ํ๊ธฐ) - `timestamps` ๋ชจ๋ ๋ง์ด๊ทธ๋ ์ด์ ํ์ผ์ ์์ฑํ ํ `php artisan migrate`๋ฅผ ์คํํ์ธ์. ### ๊ธฐ๋ ๊ฒฐ๊ณผ - 4๊ฐ ํ ์ด๋ธ์ด SQLite ๋ฐ์ดํฐ๋ฒ ์ด์ค์ ์์ฑ๋จ - ์ธ๋ ํค ๊ด๊ณ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ค์ ๋จ
๊ธฐ๋ ๊ฒฐ๊ณผ: season_passes, purchases, performances, checkins ํ ์ด๋ธ์ด ์์ฑ๋๊ณ , ์ธ๋ ํค ๊ด๊ณ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ค์ ๋์ด ์์ต๋๋ค. `php artisan migrate:status`๋ก ํ์ธ ๊ฐ๋ฅํฉ๋๋ค.
## Eloquent ๋ชจ๋ธ ์์ฑ ๋ฐ ๊ด๊ณ ์ค์
๋ค์ 4๊ฐ ๋ชจ๋ธ์ ์์ฑํ๊ณ ๊ด๊ณ๋ฅผ ์ค์ ํ์ธ์:
### 1. SeasonPass ๋ชจ๋ธ
```bash
php artisan make:model SeasonPass
```
`app/Models/SeasonPass.php`:
- `$fillable`: name, description, price, included_performances, valid_from, valid_until, poster_image
- `$casts`: valid_from, valid_until์ 'date'๋ก ์บ์คํ
- ๊ด๊ณ: `public function purchases() { return $this->hasMany(Purchase::class); }`
### 2. Purchase ๋ชจ๋ธ
```bash
php artisan make:model Purchase
```
`app/Models/Purchase.php`:
- `$fillable`: user_id, season_pass_id, customer_name, customer_email, customer_phone, purchased_at, remaining_uses
- `$casts`: purchased_at์ 'datetime'์ผ๋ก ์บ์คํ
- ๊ด๊ณ:
- `public function user() { return $this->belongsTo(User::class); }`
- `public function seasonPass() { return $this->belongsTo(SeasonPass::class); }`
- `public function checkins() { return $this->hasMany(Checkin::class); }`
### 3. Performance ๋ชจ๋ธ
```bash
php artisan make:model Performance
```
`app/Models/Performance.php`:
- `$fillable`: title, description, performance_date, venue, poster_image
- `$casts`: performance_date๋ฅผ 'datetime'์ผ๋ก ์บ์คํ
- ๊ด๊ณ: `public function checkins() { return $this->hasMany(Checkin::class); }`
### 4. Checkin ๋ชจ๋ธ
```bash
php artisan make:model Checkin
```
`app/Models/Checkin.php`:
- `$fillable`: purchase_id, performance_id, checked_in_at, rating, review
- `$casts`: checked_in_at์ 'datetime'์ผ๋ก ์บ์คํ
- ๊ด๊ณ:
- `public function purchase() { return $this->belongsTo(Purchase::class); }`
- `public function performance() { return $this->belongsTo(Performance::class); }`
๋ชจ๋ ๋ชจ๋ธ ํ์ผ์ ์์ฑํ์ธ์.
### ๊ธฐ๋ ๊ฒฐ๊ณผ
- 4๊ฐ Eloquent ๋ชจ๋ธ์ด ์์ฑ๋๊ณ , ๊ด๊ณ๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ค์ ๋จ
- `php artisan tinker`์์ ๊ด๊ณ ๋ฉ์๋ ํธ์ถ ๊ฐ๋ฅ (์: `SeasonPass::first()->purchases`)
๊ธฐ๋ ๊ฒฐ๊ณผ: SeasonPass, Purchase, Performance, Checkin ๋ชจ๋ธ์ด ์์ฑ๋๊ณ , Eloquent ๊ด๊ณ(hasMany, belongsTo)๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ์ค์ ๋์ด ์์ต๋๋ค. Tinker์์ ๊ด๊ณ ๋ฉ์๋๋ฅผ ํ ์คํธํ ์ ์์ต๋๋ค.
## ์๋(Seeder)๋ก ์ํ ๋ฐ์ดํฐ ์์ฑ
๊ฐ๋ฐ/ํ
์คํธ๋ฅผ ์ํด ์ํ ๋ฐ์ดํฐ๋ฅผ ์์ฑํ์ธ์.
### 1. SeasonPassSeeder
```bash
php artisan make:seeder SeasonPassSeeder
```
`database/seeders/SeasonPassSeeder.php`:
- 3๊ฐ์ ์์ฆ ํจ์ค ์์ฑ (์: "๋ด ์์ฆ 5ํ๊ถ", "์ฌ๋ฆ ์์ฆ 10ํ๊ถ", "๊ฐ์ ์์ฆ 3ํ๊ถ")
- ๊ฐ ํจ์ค์ ๊ฐ๊ฒฉ, ํฌํจ ํ์ฐจ ์, ์ ํจ๊ธฐ๊ฐ ์ค์
- `SeasonPass::create([...])` ์ฌ์ฉ
### 2. PerformanceSeeder
```bash
php artisan make:seeder PerformanceSeeder
```
`database/seeders/PerformanceSeeder.php`:
- 10๊ฐ ์ ๋์ ๊ณต์ฐ ํ์ฐจ ์์ฑ (์: "์์ปค์ค ์คํ๋ ์ผ", "๊ณต์ค ๊ณก์ ํน์ง", "๋ง์ ๊ณผ ์ ๊ธ๋ง์ ๋ฐค" ๋ฑ)
- ๊ฐ ๊ณต์ฐ์ ๋ ์ง, ์ฅ์ ์ค์ (๋ฏธ๋ ๋ ์ง ํฌํจ)
- `Performance::create([...])` ์ฌ์ฉ
### 3. DatabaseSeeder์ ๋ฑ๋ก
`database/seeders/DatabaseSeeder.php`์ `run()` ๋ฉ์๋์์:
```php
$this->call([
SeasonPassSeeder::class,
PerformanceSeeder::class,
]);
```
์๋ ์คํ:
```bash
php artisan db:seed
```
### ๊ธฐ๋ ๊ฒฐ๊ณผ
- season_passes ํ
์ด๋ธ์ 3๊ฐ ๋ ์ฝ๋
- performances ํ
์ด๋ธ์ 10๊ฐ ๋ ์ฝ๋
- `php artisan tinker`์์ `SeasonPass::count()`, `Performance::count()` ํ์ธ ๊ฐ๋ฅ
๊ธฐ๋ ๊ฒฐ๊ณผ: ์๋๊ฐ ์คํ๋์ด season_passes ํ ์ด๋ธ์ 3๊ฐ, performances ํ ์ด๋ธ์ 10๊ฐ์ ์ํ ๋ฐ์ดํฐ๊ฐ ์์ฑ๋ฉ๋๋ค. Tinker์์ ๋ฐ์ดํฐ ํ์ธ ๊ฐ๋ฅํฉ๋๋ค.
## ์์ฆ ํจ์ค ๋ชฉ๋ก ํ์ด์ง (ํ) ๊ตฌํ
Laravel ์ปจํธ๋กค๋ฌ์ React ์ปดํฌ๋ํธ๋ฅผ ์์ฑํ์ฌ ์์ฆ ํจ์ค ๋ชฉ๋ก์ ํ์ํ์ธ์.
### 1. SeasonPassController ์์ฑ
```bash
php artisan make:controller SeasonPassController
```
`app/Http/Controllers/SeasonPassController.php`์ `index()` ๋ฉ์๋:
```php
public function index()
{
$seasonPasses = SeasonPass::orderBy('valid_from', 'desc')->get();
return Inertia::render('SeasonPass/Index', [
'seasonPasses' => $seasonPasses
]);
}
```
### 2. ๋ผ์ฐํธ ๋ฑ๋ก
`routes/web.php`์ ์ถ๊ฐ:
```php
use App\Http\Controllers\SeasonPassController;
Route::get('/', [SeasonPassController::class, 'index'])->name('home');
```
### 3. React ์ปดํฌ๋ํธ ์์ฑ
`resources/js/Pages/SeasonPass/Index.jsx` ํ์ผ ์์ฑ:
- props๋ก `seasonPasses` ๋ฐฐ์ด ๋ฐ๊ธฐ
- Tailwind CSS๋ก ์นด๋ ๋ ์ด์์ ๊ตฌํ (๊ทธ๋ฆฌ๋ ๋๋ flexbox)
- ๊ฐ ์นด๋์ ํจ์ค ์ด๋ฆ, ์ค๋ช
, ๊ฐ๊ฒฉ, ํฌํจ ํ์ฐจ ์, ์ ํจ๊ธฐ๊ฐ ํ์
- "์์ธํ ๋ณด๊ธฐ" ๋ฒํผ ์ถ๊ฐ (์์ง ๋งํฌ ์์, ๋ค์ ๋จ๊ณ์์ ๊ตฌํ)
์์ ๊ตฌ์กฐ:
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';
export default function Index({ auth, seasonPasses }) {
return (
<AuthenticatedLayout user={auth.user}>
<Head title="์์ฆ ํจ์ค ๋ชฉ๋ก" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold mb-6">๐ช ์์ฆ ํจ์ค ๋ชฉ๋ก</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{seasonPasses.map(pass => (
<div key={pass.id} className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-2">{pass.name}</h2>
<p className="text-gray-600 mb-4">{pass.description}</p>
<div className="text-2xl font-bold text-indigo-600 mb-2">
{pass.price.toLocaleString()}์
</div>
<p className="text-sm text-gray-500 mb-4">
ํฌํจ ํ์ฐจ: {pass.included_performances}ํ
</p>
<p className="text-xs text-gray-400 mb-4">
์ ํจ๊ธฐ๊ฐ: {pass.valid_from} ~ {pass.valid_until}
</p>
<button className="w-full bg-indigo-600 text-white py-2 rounded hover:bg-indigo-700">
์์ธํ ๋ณด๊ธฐ
</button>
</div>
))}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
๋ธ๋ผ์ฐ์ ์์ http://localhost:8000 ์ ์ํ์ฌ ์์ฆ ํจ์ค ๋ชฉ๋ก์ด ํ์๋๋์ง ํ์ธํ์ธ์.
### ๊ธฐ๋ ๊ฒฐ๊ณผ
- ํ ํ์ด์ง์ ์์ฆ ํจ์ค 3๊ฐ๊ฐ ์นด๋ ํํ๋ก ํ์๋จ
- ๊ฐ ์นด๋์ ์ด๋ฆ, ์ค๋ช
, ๊ฐ๊ฒฉ, ํ์ฐจ ์, ์ ํจ๊ธฐ๊ฐ ํ์
- Tailwind CSS ์คํ์ผ ์ ์ฉ๋จ
๊ธฐ๋ ๊ฒฐ๊ณผ: ํ ํ์ด์ง(/)์์ ์์ฆ ํจ์ค ๋ชฉ๋ก์ด ๊ทธ๋ฆฌ๋ ๋ ์ด์์์ผ๋ก ํ์๋ฉ๋๋ค. ๊ฐ ์นด๋๋ ํจ์ค ์ ๋ณด๋ฅผ ๋ณด๊ธฐ ์ข๊ฒ ํ์ํ๋ฉฐ, "์์ธํ ๋ณด๊ธฐ" ๋ฒํผ์ด ์์ต๋๋ค.
## ์์ฆ ํจ์ค ์์ธ ํ์ด์ง & ๊ตฌ๋งค ํผ ๊ตฌํ
### 1. SeasonPassController์ show() ๋ฉ์๋ ์ถ๊ฐ
`app/Http/Controllers/SeasonPassController.php`:
```php
public function show(SeasonPass $seasonPass)
{
return Inertia::render('SeasonPass/Show', [
'seasonPass' => $seasonPass
]);
}
```
### 2. ๋ผ์ฐํธ ์ถ๊ฐ
`routes/web.php`:
```php
Route::get('/season-pass/{seasonPass}', [SeasonPassController::class, 'show'])->name('season-pass.show');
```
### 3. React ์ปดํฌ๋ํธ ์์ฑ
`resources/js/Pages/SeasonPass/Show.jsx`:
- props๋ก `seasonPass` ๊ฐ์ฒด ๋ฐ๊ธฐ
- ํจ์ค ์์ธ ์ ๋ณด ํ์ (์ด๋ฆ, ์ค๋ช
, ๊ฐ๊ฒฉ, ํฌํจ ํ์ฐจ, ์ ํจ๊ธฐ๊ฐ)
- ๊ตฌ๋งค ํผ ์ถ๊ฐ:
- ๊ณ ๊ฐ ์ด๋ฆ (customer_name)
- ์ด๋ฉ์ผ (customer_email)
- ์ ํ๋ฒํธ (customer_phone)
- "๊ตฌ๋งคํ๊ธฐ" ๋ฒํผ
- Inertia.js์ `useForm` ํ
์ฌ์ฉํ์ฌ ํผ ๋ฐ์ดํฐ ๊ด๋ฆฌ
- ํผ ์ ์ถ ์ POST ์์ฒญ (๋ค์ ๋จ๊ณ์์ ๊ตฌํ)
์์:
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm } from '@inertiajs/react';
export default function Show({ auth, seasonPass }) {
const { data, setData, post, processing, errors } = useForm({
customer_name: auth.user?.name || '',
customer_email: auth.user?.email || '',
customer_phone: '',
});
const handleSubmit = (e) => {
e.preventDefault();
post(route('purchase.store', seasonPass.id));
};
return (
<AuthenticatedLayout user={auth.user}>
<Head title={seasonPass.name} />
<div className="py-12">
<div className="max-w-4xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow-md p-8">
<h1 className="text-3xl font-bold mb-4">{seasonPass.name}</h1>
<p className="text-gray-600 mb-6">{seasonPass.description}</p>
<div className="grid grid-cols-2 gap-4 mb-8">
<div>
<span className="text-gray-500">๊ฐ๊ฒฉ:</span>
<span className="ml-2 text-2xl font-bold text-indigo-600">
{seasonPass.price.toLocaleString()}์
</span>
</div>
<div>
<span className="text-gray-500">ํฌํจ ํ์ฐจ:</span>
<span className="ml-2 text-xl font-semibold">
{seasonPass.included_performances}ํ
</span>
</div>
<div className="col-span-2">
<span className="text-gray-500">์ ํจ๊ธฐ๊ฐ:</span>
<span className="ml-2">
{seasonPass.valid_from} ~ {seasonPass.valid_until}
</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<h2 className="text-2xl font-semibold mb-4">๊ตฌ๋งค ์ ๋ณด ์
๋ ฅ</h2>
<div>
<label className="block text-sm font-medium mb-1">์ด๋ฆ</label>
<input
type="text"
value={data.customer_name}
onChange={e => setData('customer_name', e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
{errors.customer_name && <p className="text-red-500 text-sm">{errors.customer_name}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">์ด๋ฉ์ผ</label>
<input
type="email"
value={data.customer_email}
onChange={e => setData('customer_email', e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
{errors.customer_email && <p className="text-red-500 text-sm">{errors.customer_email}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-1">์ ํ๋ฒํธ</label>
<input
type="tel"
value={data.customer_phone}
onChange={e => setData('customer_phone', e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
{errors.customer_phone && <p className="text-red-500 text-sm">{errors.customer_phone}</p>}
</div>
<button
type="submit"
disabled={processing}
className="w-full bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{processing ? '์ฒ๋ฆฌ ์ค...' : '๊ตฌ๋งคํ๊ธฐ'}
</button>
</form>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
### 4. Index.jsx์ "์์ธํ ๋ณด๊ธฐ" ๋ฒํผ ๋งํฌ ์ฐ๊ฒฐ
`resources/js/Pages/SeasonPass/Index.jsx`์ ๋ฒํผ์:
```jsx
import { Link } from '@inertiajs/react';
<Link
href={route('season-pass.show', pass.id)}
className="block w-full bg-indigo-600 text-white py-2 rounded hover:bg-indigo-700 text-center"
>
์์ธํ ๋ณด๊ธฐ
</Link>
```
๋ธ๋ผ์ฐ์ ์์ ์์ฆ ํจ์ค ์นด๋๋ฅผ ํด๋ฆญํ์ฌ ์์ธ ํ์ด์ง๊ฐ ํ์๋๋์ง ํ์ธํ์ธ์.
### ๊ธฐ๋ ๊ฒฐ๊ณผ
- ์์ฆ ํจ์ค ์์ธ ํ์ด์ง์ ํจ์ค ์ ๋ณด์ ๊ตฌ๋งค ํผ ํ์
- ํผ ํ๋์ ๊ธฐ๋ณธ๊ฐ ์๋ ์
๋ ฅ (๋ก๊ทธ์ธํ ์ฌ์ฉ์ ์ด๋ฆ/์ด๋ฉ์ผ)
- "๊ตฌ๋งคํ๊ธฐ" ๋ฒํผ ํด๋ฆญ ์ ์์ง ๋์ํ์ง ์์ (๋ค์ ๋จ๊ณ์์ ๊ตฌํ)
๊ธฐ๋ ๊ฒฐ๊ณผ: ์์ฆ ํจ์ค ์์ธ ํ์ด์ง๊ฐ ํ์๋๊ณ , ๊ตฌ๋งค ํผ์ด ๋ ๋๋ง๋ฉ๋๋ค. ๋ก๊ทธ์ธํ ์ฌ์ฉ์ ์ ๋ณด๊ฐ ์๋์ผ๋ก ์ ๋ ฅ๋๋ฉฐ, ํผ ์ ์ถ ์ค๋น๊ฐ ์๋ฃ๋์์ต๋๋ค.
## ๊ตฌ๋งค ๊ธฐ๋ฅ ๊ตฌํ (Purchase ์์ฑ)
### 1. PurchaseController ์์ฑ
```bash
php artisan make:controller PurchaseController
```
`app/Http/Controllers/PurchaseController.php`์ `store()` ๋ฉ์๋:
```php
use App\Models\Purchase;
use App\Models\SeasonPass;
use Illuminate\Http\Request;
public function store(Request $request, SeasonPass $seasonPass)
{
$validated = $request->validate([
'customer_name' => 'required|string|max:255',
'customer_email' => 'required|email|max:255',
'customer_phone' => 'required|string|max:20',
]);
Purchase::create([
'user_id' => auth()->id(),
'season_pass_id' => $seasonPass->id,
'customer_name' => $validated['customer_name'],
'customer_email' => $validated['customer_email'],
'customer_phone' => $validated['customer_phone'],
'purchased_at' => now(),
'remaining_uses' => $seasonPass->included_performances,
]);
return redirect()->route('my-passes')->with('success', '์์ฆ ํจ์ค ๊ตฌ๋งค๊ฐ ์๋ฃ๋์์ต๋๋ค!');
}
```
### 2. ๋ผ์ฐํธ ์ถ๊ฐ
`routes/web.php`:
```php
use App\Http\Controllers\PurchaseController;
Route::middleware('auth')->group(function () {
Route::post('/purchase/{seasonPass}', [PurchaseController::class, 'store'])->name('purchase.store');
});
```
### 3. "๋ด ํจ์ค" ํ์ด์ง ์ปจํธ๋กค๋ฌ ๋ฉ์๋ ์ถ๊ฐ
`app/Http/Controllers/PurchaseController.php`์ `index()` ๋ฉ์๋ ์ถ๊ฐ:
```php
public function index()
{
$purchases = Purchase::with('seasonPass')
->where('user_id', auth()->id())
->orderBy('purchased_at', 'desc')
->get();
return Inertia::render('Purchase/Index', [
'purchases' => $purchases
]);
}
```
### 4. ๋ผ์ฐํธ ์ถ๊ฐ
```php
Route::middleware('auth')->group(function () {
Route::get('/my-passes', [PurchaseController::class, 'index'])->name('my-passes');
});
```
### 5. React ์ปดํฌ๋ํธ ์์ฑ
`resources/js/Pages/Purchase/Index.jsx`:
- props๋ก `purchases` ๋ฐฐ์ด ๋ฐ๊ธฐ
- ๊ฐ ๊ตฌ๋งค ๋ด์ญ์ ์นด๋๋ก ํ์ (์์ฆ ํจ์ค ์ด๋ฆ, ๊ตฌ๋งค ๋ ์ง, ๋จ์ ํ์ฐจ ์)
- ํ๋ก๊ทธ๋ ์ค ๋ฐ๋ก ์ฌ์ฉ๋ฅ ํ์ (์: 5ํ ์ค 2ํ ์ฌ์ฉ = 40%)
์์:
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';
export default function Index({ auth, purchases }) {
return (
<AuthenticatedLayout user={auth.user}>
<Head title="๋ด ํจ์ค" />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold mb-6">๐๏ธ ๋ด ํจ์ค</h1>
{purchases.length === 0 ? (
<p className="text-gray-500">๊ตฌ๋งคํ ํจ์ค๊ฐ ์์ต๋๋ค.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{purchases.map(purchase => {
const totalUses = purchase.season_pass.included_performances;
const usedUses = totalUses - purchase.remaining_uses;
const usagePercent = (usedUses / totalUses) * 100;
return (
<div key={purchase.id} className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-2">{purchase.season_pass.name}</h2>
<p className="text-sm text-gray-500 mb-4">
๊ตฌ๋งค์ผ: {new Date(purchase.purchased_at).toLocaleDateString()}
</p>
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span>์ฌ์ฉ ํํฉ</span>
<span>{usedUses} / {totalUses}ํ</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-600 h-2 rounded-full"
style={{ width: `${usagePercent}%` }}
></div>
</div>
</div>
<p className="text-lg font-semibold text-indigo-600">
๋จ์ ํ์: {purchase.remaining_uses}ํ
</p>
</div>
);
})}
</div>
)}
</div>
</div>
</AuthenticatedLayout>
);
}
```
### 6. ๋ค๋น๊ฒ์ด์
์ "๋ด ํจ์ค" ๋งํฌ ์ถ๊ฐ
`resources/js/Layouts/AuthenticatedLayout.jsx`์ ๋ค๋น๊ฒ์ด์
์:
```jsx
<Link href={route('my-passes')} className="...">
๋ด ํจ์ค
</Link>
```
์์ฆ ํจ์ค๋ฅผ ๊ตฌ๋งคํ๊ณ , "๋ด ํจ์ค" ํ์ด์ง์์ ๊ตฌ๋งค ๋ด์ญ์ด ํ์๋๋์ง ํ์ธํ์ธ์.
### ๊ธฐ๋ ๊ฒฐ๊ณผ
- ์์ฆ ํจ์ค ๊ตฌ๋งค ์ Purchase ๋ ์ฝ๋๊ฐ ์์ฑ๋จ
- "๋ด ํจ์ค" ํ์ด์ง์ ๊ตฌ๋งคํ ํจ์ค ๋ชฉ๋ก ํ์
- ๊ฐ ํจ์ค์ ์ฌ์ฉ๋ฅ ์ด ํ๋ก๊ทธ๋ ์ค ๋ฐ๋ก ํ์๋จ
๊ธฐ๋ ๊ฒฐ๊ณผ: ์์ฆ ํจ์ค ๊ตฌ๋งค๊ฐ ์ ์์ ์ผ๋ก ์ฒ๋ฆฌ๋๊ณ , "๋ด ํจ์ค" ํ์ด์ง์์ ๊ตฌ๋งค ๋ด์ญ๊ณผ ์ฌ์ฉ ํํฉ(ํ๋ก๊ทธ๋ ์ค ๋ฐ)์ด ํ์๋ฉ๋๋ค. ๋จ์ ํ์๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ๊ณ์ฐ๋ฉ๋๋ค.
## ๊ณต์ฐ ํ์ฐจ ๋ชฉ๋ก & ์ฒดํฌ์ธ ๊ธฐ๋ฅ ๊ตฌํ
### 1. PerformanceController ์์ฑ
```bash
php artisan make:controller PerformanceController
```
`app/Http/Controllers/PerformanceController.php`์ `index()` ๋ฉ์๋:
```php
use App\Models\Performance;
public function index()
{
$performances = Performance::orderBy('performance_date', 'asc')->get();
return Inertia::render('Performance/Index', [
'performances' => $performances
]);
}
```
### 2. ๋ผ์ฐํธ ์ถ๊ฐ
`routes/web.php`:
```php
use App\Http\Controllers\PerformanceController;
Route::get('/performances', [PerformanceController::class, 'index'])->name('performances.index');
```
### 3. React ์ปดํฌ๋ํธ ์์ฑ
`resources/js/Pages/Performance/Index.jsx`:
- props๋ก `performances` ๋ฐฐ์ด ๋ฐ๊ธฐ
- ๊ฐ ๊ณต์ฐ์ ์นด๋๋ก ํ์ (์ ๋ชฉ, ์ค๋ช
, ๋ ์ง, ์ฅ์)
- "์ฒดํฌ์ธํ๊ธฐ" ๋ฒํผ ์ถ๊ฐ (๋ก๊ทธ์ธํ ์ฌ์ฉ์๋ง ํ์)
### 4. CheckinController ์์ฑ
```bash
php artisan make:controller CheckinController
```
`app/Http/Controllers/CheckinController.php`์ `store()` ๋ฉ์๋:
```php
use App\Models\Checkin;
use App\Models\Purchase;
use App\Models\Performance;
use Illuminate\Http\Request;
public function store(Request $request, Performance $performance)
{
// ์ฌ์ฉ์์ ํจ์ค ์ค ๋จ์ ํ์๊ฐ ์๋ ๊ฒ ์ฐพ๊ธฐ
$purchase = Purchase::where('user_id', auth()->id())
->where('remaining_uses', '>', 0)
->first();
if (!$purchase) {
return back()->withErrors(['error' => '์ฌ์ฉ ๊ฐ๋ฅํ ํจ์ค๊ฐ ์์ต๋๋ค.']);
}
// ์ด๋ฏธ ์ฒดํฌ์ธํ๋์ง ํ์ธ
$existingCheckin = Checkin::where('purchase_id', $purchase->id)
->where('performance_id', $performance->id)
->exists();
if ($existingCheckin) {
return back()->withErrors(['error' => '์ด๋ฏธ ์ฒดํฌ์ธํ ๊ณต์ฐ์
๋๋ค.']);
}
// ์ฒดํฌ์ธ ์์ฑ
Checkin::create([
'purchase_id' => $purchase->id,
'performance_id' => $performance->id,
'checked_in_at' => now(),
]);
// ๋จ์ ํ์ ๊ฐ์
$purchase->decrement('remaining_uses');
return back()->with('success', '์ฒดํฌ์ธ์ด ์๋ฃ๋์์ต๋๋ค!');
}
```
### 5. ๋ผ์ฐํธ ์ถ๊ฐ
```php
Route::middleware('auth')->group(function () {
Route::post('/checkin/{performance}', [CheckinController::class, 'store'])->name('checkin.store');
});
```
### 6. Performance/Index.jsx ์
๋ฐ์ดํธ
์ฒดํฌ์ธ ๋ฒํผ ์ถ๊ฐ:
```jsx
import { useForm } from '@inertiajs/react';
export default function Index({ auth, performances }) {
const { post, processing } = useForm();
const handleCheckin = (performanceId) => {
if (confirm('์ด ๊ณต์ฐ์ ์ฒดํฌ์ธํ์๊ฒ ์ต๋๊น?')) {
post(route('checkin.store', performanceId));
}
};
return (
<AuthenticatedLayout user={auth.user}>
<Head title="๊ณต์ฐ ์ผ์ " />
<div className="py-12">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<h1 className="text-3xl font-bold mb-6">๐ช ๊ณต์ฐ ์ผ์ </h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{performances.map(performance => (
<div key={performance.id} className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-2">{performance.title}</h2>
<p className="text-gray-600 mb-4">{performance.description}</p>
<p className="text-sm text-gray-500 mb-2">
๐
{new Date(performance.performance_date).toLocaleString()}
</p>
<p className="text-sm text-gray-500 mb-4">
๐ {performance.venue}
</p>
{auth.user && (
<button
onClick={() => handleCheckin(performance.id)}
disabled={processing}
className="w-full bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50"
>
์ฒดํฌ์ธํ๊ธฐ
</button>
)}
</div>
))}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
### 7. ๋ค๋น๊ฒ์ด์
์ "๊ณต์ฐ ์ผ์ " ๋งํฌ ์ถ๊ฐ
`resources/js/Layouts/AuthenticatedLayout.jsx`:
```jsx
<Link href={route('performances.index')} className="...">
๊ณต์ฐ ์ผ์
</Link>
```
๊ณต์ฐ ์ผ์ ํ์ด์ง์์ ์ฒดํฌ์ธํ๊ณ , "๋ด ํจ์ค" ํ์ด์ง์์ ๋จ์ ํ์๊ฐ ๊ฐ์ํ๋์ง ํ์ธํ์ธ์.
### ๊ธฐ๋ ๊ฒฐ๊ณผ
- ๊ณต์ฐ ์ผ์ ํ์ด์ง์ ๋ชจ๋ ๊ณต์ฐ ํ์
- ์ฒดํฌ์ธ ๋ฒํผ ํด๋ฆญ ์ Checkin ๋ ์ฝ๋ ์์ฑ ๋ฐ ๋จ์ ํ์ ๊ฐ์
- ์ค๋ณต ์ฒดํฌ์ธ ๋ฐฉ์ง
- ํจ์ค๊ฐ ์์ผ๋ฉด ์๋ฌ ๋ฉ์์ง ํ์
๊ธฐ๋ ๊ฒฐ๊ณผ: ๊ณต์ฐ ์ผ์ ํ์ด์ง๊ฐ ํ์๋๊ณ , ์ฒดํฌ์ธ ๊ธฐ๋ฅ์ด ์ ์ ๋์ํฉ๋๋ค. ์ฒดํฌ์ธ ์ ๋จ์ ํ์๊ฐ ๊ฐ์ํ๋ฉฐ, ์ค๋ณต ์ฒดํฌ์ธ์ด ๋ฐฉ์ง๋ฉ๋๋ค.
## ๊ด๋ ํ์คํ ๋ฆฌ & ํ๊ธฐ ๊ธฐ๋ฅ ๊ตฌํ
### 1. PurchaseController์ show() ๋ฉ์๋ ์ถ๊ฐ
`app/Http/Controllers/PurchaseController.php`:
```php
public function show(Purchase $purchase)
{
// ๋ณธ์ธ์ ํจ์ค๋ง ์กฐํ ๊ฐ๋ฅ
if ($purchase->user_id !== auth()->id()) {
abort(403);
}
$purchase->load(['seasonPass', 'checkins.performance']);
return Inertia::render('Purchase/Show', [
'purchase' => $purchase
]);
}
```
### 2. ๋ผ์ฐํธ ์ถ๊ฐ
```php
Route::middleware('auth')->group(function () {
Route::get('/my-passes/{purchase}', [PurchaseController::class, 'show'])->name('my-passes.show');
});
```
### 3. React ์ปดํฌ๋ํธ ์์ฑ
`resources/js/Pages/Purchase/Show.jsx`:
- props๋ก `purchase` ๊ฐ์ฒด ๋ฐ๊ธฐ (checkins ๊ด๊ณ ํฌํจ)
- ํจ์ค ์ ๋ณด ํ์ (์ด๋ฆ, ๊ตฌ๋งค์ผ, ์ฌ์ฉ ํํฉ)
- ๊ด๋ ํ์คํ ๋ฆฌ ํ์๋ผ์ธ ํ์ (์ฒดํฌ์ธํ ๊ณต์ฐ ๋ชฉ๋ก)
- ๊ฐ ๊ณต์ฐ์ ๋ณ์ /ํ๊ธฐ ๋จ๊ธฐ๊ธฐ ํผ ์ถ๊ฐ
์์:
```jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm } from '@inertiajs/react';
import { useState } from 'react';
export default function Show({ auth, purchase }) {
const [editingCheckin, setEditingCheckin] = useState(null);
const { data, setData, put, processing } = useForm({
rating: 0,
review: '',
});
const handleReviewSubmit = (e, checkinId) => {
e.preventDefault();
put(route('checkin.update', checkinId), {
onSuccess: () => setEditingCheckin(null),
});
};
const totalUses = purchase.season_pass.included_performances;
const usedUses = totalUses - purchase.remaining_uses;
const usagePercent = (usedUses / totalUses) * 100;
return (
<AuthenticatedLayout user={auth.user}>
<Head title="ํจ์ค ์์ธ" />
<div className="py-12">
<div className="max-w-4xl mx-auto sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow-md p-8 mb-6">
<h1 className="text-3xl font-bold mb-4">{purchase.season_pass.name}</h1>
<p className="text-sm text-gray-500 mb-4">
๊ตฌ๋งค์ผ: {new Date(purchase.purchased_at).toLocaleDateString()}
</p>
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span>์ฌ์ฉ ํํฉ</span>
<span>{usedUses} / {totalUses}ํ</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full"
style={{ width: `${usagePercent}%` }}
></div>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-2xl font-semibold mb-6">๊ด๋ ํ์คํ ๋ฆฌ</h2>
{purchase.checkins.length === 0 ? (
<p className="text-gray-500">์์ง ๊ด๋ํ ๊ณต์ฐ์ด ์์ต๋๋ค.</p>
) : (
<div className="space-y-6">
{purchase.checkins.map(checkin => (
<div key={checkin.id} className="border-l-4 border-indigo-600 pl-4">
<h3 className="text-lg font-semibold">{checkin.performance.title}</h3>
<p className="text-sm text-gray-500 mb-2">
์ฒดํฌ์ธ: {new Date(checkin.checked_in_at).toLocaleString()}
</p>
<p className="text-sm text-gray-600 mb-2">
๐ {checkin.performance.venue}
</p>
{checkin.rating ? (
<div className="bg-gray-50 p-4 rounded">
<div className="flex items-center mb-2">
<span className="text-yellow-500 text-xl">
{'โญ'.repeat(checkin.rating)}
</span>
</div>
<p className="text-gray-700">{checkin.review}</p>
</div>
) : (
editingCheckin === checkin.id ? (
<form onSubmit={(e) => handleReviewSubmit(e, checkin.id)} className="bg-gray-50 p-4 rounded">
<div className="mb-3">
<label className="block text-sm font-medium mb-1">๋ณ์ </label>
<select
value={data.rating}
onChange={e => setData('rating', e.target.value)}
className="w-full border rounded px-3 py-2"
required
>
<option value="">์ ํํ์ธ์</option>
<option value="1">โญ 1์ </option>
<option value="2">โญโญ 2์ </option>
<option value="3">โญโญโญ 3์ </option>
<option value="4">โญโญโญโญ 4์ </option>
<option value="5">โญโญโญโญโญ 5์ </option>
</select>
</div>
<div className="mb-3">
<label className="block text-sm font-medium mb-1">ํ๊ธฐ</label>
<textarea
value={data.review}
onChange={e => setData('review', e.target.value)}
className="w-full border rounded px-3 py-2"
rows="3"
placeholder="๊ณต์ฐ ์๊ฐ์ ๋จ๊ฒจ์ฃผ์ธ์"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={processing}
className="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700"
>
์ ์ฅ
</button>
<button
type="button"
onClick={() => setEditingCheckin(null)}
className="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
>
์ทจ์
</button>
</div>
</form>
) : (
<button
onClick={() => setEditingCheckin(checkin.id)}
className="text-indigo-600 hover:underline text-sm"
>
ํ๊ธฐ ๋จ๊ธฐ๊ธฐ
</button>
)
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
```
### 4. CheckinController์ update() ๋ฉ์๋ ์ถ๊ฐ
`app/Http/Controllers/CheckinController.php`:
```php
public function update(Request $request, Checkin $checkin)
{
// ๋ณธ์ธ์ ์ฒดํฌ์ธ๋ง ์์ ๊ฐ๋ฅ
if ($checkin->purchase->user_id !== auth()->id()) {
abort(403);
}
$validated = $request->validate([
'rating' => 'required|integer|min:1|max:5',
'review' => 'nullable|string|max:1000',
]);
$checkin->update($validated);
return back()->with('success', 'ํ๊ธฐ๊ฐ ์ ์ฅ๋์์ต๋๋ค!');
}
```
### 5. ๋ผ์ฐํธ ์ถ๊ฐ
```php
Route::middleware('auth')->group(function () {
Route::put('/checkin/{checkin}', [CheckinController::class, 'update'])->name('checkin.update');
});
```
### 6. Purchase/Index.jsx์์ ํจ์ค ์์ธ ๋งํฌ ์ถ๊ฐ
๊ฐ ํจ์ค ์นด๋์ "์์ธ ๋ณด๊ธฐ" ๋ฒํผ ์ถ๊ฐ:
```jsx
import { Link } from '@inertiajs/react';
<Link
href={route('my-passes.show', purchase.id)}
className="block w-full bg-indigo-600 text-white py-2 rounded hover:bg-indigo-700 text-center mt-4"
>
์์ธ ๋ณด๊ธฐ
</Link>
```
"๋ด ํจ์ค" ํ์ด์ง์์ ํจ์ค๋ฅผ ํด๋ฆญํ์ฌ ๊ด๋ ํ์คํ ๋ฆฌ๊ฐ ํ์๋๋์ง ํ์ธํ๊ณ , ํ๊ธฐ๋ฅผ ๋จ๊ฒจ๋ณด์ธ์.
### ๊ธฐ๋ ๊ฒฐ๊ณผ
- ํจ์ค ์์ธ ํ์ด์ง์ ์ฒดํฌ์ธํ ๊ณต์ฐ ๋ชฉ๋ก์ด ํ์๋ผ์ธ์ผ๋ก ํ์๋จ
- ๊ฐ ๊ณต์ฐ์ ๋ณ์ /ํ๊ธฐ ๋จ๊ธฐ๊ธฐ ๊ฐ๋ฅ
- ํ๊ธฐ ์ ์ฅ ํ ํ์ด์ง์ ๋ฐ๋ก ๋ฐ์๋จ
๊ธฐ๋ ๊ฒฐ๊ณผ: ํจ์ค ์์ธ ํ์ด์ง์์ ๊ด๋ ํ์คํ ๋ฆฌ๊ฐ ํ์๋ผ์ธ์ผ๋ก ํ์๋๊ณ , ๊ฐ ๊ณต์ฐ์ ๋ณ์ ๊ณผ ํ๊ธฐ๋ฅผ ๋จ๊ธธ ์ ์์ต๋๋ค. ํ๊ธฐ๊ฐ ์ ์ฅ๋๋ฉด ํ๋ฉด์ ์ฆ์ ๋ฐ์๋ฉ๋๋ค.
## ์ต์ข
๋ง๋ฌด๋ฆฌ & ๊ฐ์ ์ฌํญ
### 1. ํ๋์ ๋ฉ์์ง ํ์ ์ปดํฌ๋ํธ ์ถ๊ฐ
`resources/js/Components/FlashMessage.jsx` ์์ฑ:
```jsx
import { usePage } from '@inertiajs/react';
import { useEffect, useState } from 'react';
export default function FlashMessage() {
const { flash } = usePage().props;
const [visible, setVisible] = useState(false);
useEffect(() => {
if (flash.success || flash.error) {
setVisible(true);
setTimeout(() => setVisible(false), 3000);
}
}, [flash]);
if (!visible) return null;
return (
<div className="fixed top-4 right-4 z-50 animate-fade-in">
{flash.success && (
<div className="bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg">
โ {flash.success}
</div>
)}
{flash.error && (
<div className="bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg">
โ {flash.error}
</div>
)}
</div>
);
}
```
`resources/js/Layouts/AuthenticatedLayout.jsx`์ ์ถ๊ฐ:
```jsx
import FlashMessage from '@/Components/FlashMessage';
// Layout ๋ด๋ถ์
<FlashMessage />
```
### 2. ํ ํ์ด์ง์ ํ์ด๋ก ์น์
์ถ๊ฐ
`resources/js/Pages/SeasonPass/Index.jsx` ์๋จ์:
```jsx
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-16 mb-8">
<div className="max-w-7xl mx-auto text-center px-4">
<h1 className="text-5xl font-bold mb-4">๐ช ์์ปค์ค ์์ฆ ํจ์ค</h1>
<p className="text-xl">์ฌ๋ฌ ๊ณต์ฐ์ ํ ๋ฒ์! ์์ฆ ํจ์ค๋ก ๋ ์ ๋ ดํ๊ฒ ์ฆ๊ธฐ์ธ์</p>
</div>
</div>
```
### 3. ํจ์ค ์ ํจ๊ธฐ๊ฐ ๊ฒ์ฆ ์ถ๊ฐ
`app/Http/Controllers/CheckinController.php`์ `store()` ๋ฉ์๋์ ์ ํจ๊ธฐ๊ฐ ์ฒดํฌ ์ถ๊ฐ:
```php
// ํจ์ค ์ ํจ๊ธฐ๊ฐ ํ์ธ
$seasonPass = $purchase->seasonPass;
if (now()->lt($seasonPass->valid_from) || now()->gt($seasonPass->valid_until)) {
return back()->withErrors(['error' => '์ด ํจ์ค๋ ํ์ฌ ์ฌ์ฉํ ์ ์์ต๋๋ค.']);
}
```
### 4. ๊ณต์ฐ ๋ ์ง ํํฐ๋ง (์ง๋๊ฐ ๊ณต์ฐ ํ์)
`app/Http/Controllers/PerformanceController.php`:
```php
public function index()
{
$upcomingPerformances = Performance::where('performance_date', '>=', now())
->orderBy('performance_date', 'asc')
->get();
$pastPerformances = Performance::where('performance_date', '<', now())
->orderBy('performance_date', 'desc')
->get();
return Inertia::render('Performance/Index', [
'upcomingPerformances' => $upcomingPerformances,
'pastPerformances' => $pastPerformances,
]);
}
```
`Performance/Index.jsx`์์ ๋ ์น์
์ผ๋ก ๋๋์ด ํ์.
### 5. README.md ์์ฑ
ํ๋ก์ ํธ ๋ฃจํธ์ `README.md` ์์ฑ:
```markdown
# ๐ช ์์ปค์ค ์์ฆ ํจ์ค ํ๋ซํผ
Laravel 13.x + Inertia.js + React 19๋ก ๊ตฌํํ ์์ฆ ํจ์ค ํ๋งค ํ๋ซํผ์
๋๋ค.
## ๊ธฐ๋ฅ
- ์์ฆ ํจ์ค ๋ชฉ๋ก ๋ฐ ์์ธ ํ์ด์ง
- ํจ์ค ๊ตฌ๋งค (์ฃผ๋ฌธ ์ ์)
- ๋ด ํจ์ค ๋์๋ณด๋ (์ฌ์ฉ ํํฉ)
- ๊ณต์ฐ ์ผ์ ์กฐํ ๋ฐ ์ฒดํฌ์ธ
- ๊ด๋ ํ์คํ ๋ฆฌ & ๋ณ์ /ํ๊ธฐ
## ์ค์น ๋ฐฉ๋ฒ
```bash
composer install
npm install
cp .env.example .env
php artisan key:generate
touch database/database.sqlite
php artisan migrate --seed
npm run build
php artisan serve
```
## ๊ธฐ์ ์คํ
- Laravel 13.x
- Inertia.js
- React 19
- Tailwind CSS
- SQLite
```
### 6. ์ต์ข
ํ
์คํธ
- ํ์๊ฐ์
โ ์์ฆ ํจ์ค ๊ตฌ๋งค โ ๊ณต์ฐ ์ฒดํฌ์ธ โ ํ๊ธฐ ๋จ๊ธฐ๊ธฐ ์ ์ฒด ํ๋ก์ฐ ํ
์คํธ
- ํจ์ค ๋จ์ ํ์๊ฐ 0์ด ๋๋ฉด ์ฒดํฌ์ธ ๋ถ๊ฐ ํ์ธ
- ์ค๋ณต ์ฒดํฌ์ธ ๋ฐฉ์ง ํ์ธ
- ํ๋์ ๋ฉ์์ง ํ์ ํ์ธ
ํ๋ก์ ํธ๊ฐ ์์ฑ๋์์ต๋๋ค! ๐
### ์ถ๊ฐ ๊ฐ์ ์์ด๋์ด
- ํจ์ค ์ ๋ฌผํ๊ธฐ (์ด๋ฉ์ผ ์ ์ก)
- QR ์ฝ๋ ์์ฑ/์ค์บ ์ฒดํฌ์ธ
- ๊ด๋ฆฌ์ ํ์ด์ง (ํจ์ค ํ๋งค ํต๊ณ)
- ๊ฒฐ์ ์ฐ๋ (Stripe, Toss Payments)
- ์ด๋ฉ์ผ ์๋ฆผ (๊ตฌ๋งค ํ์ธ, ๊ณต์ฐ ๋ฆฌ๋ง์ธ๋)
- ํจ์ค ํ๋ถ ๊ธฐ๋ฅ
- ๊ณต์ฐ ํฌ์คํฐ ์ด๋ฏธ์ง ์
๋ก๋
๊ธฐ๋ ๊ฒฐ๊ณผ: ํ๋์ ๋ฉ์์ง, ํ์ด๋ก ์น์ , ์ ํจ๊ธฐ๊ฐ ๊ฒ์ฆ, ๊ณต์ฐ ๋ ์ง ํํฐ๋ง ๋ฑ ๊ฐ์ ์ฌํญ์ด ๋ชจ๋ ์ ์ฉ๋์ด ์์ฑ๋ ๋์ ํ๋ซํผ์ด ์์ฑ๋ฉ๋๋ค. README.md๊ฐ ์์ฑ๋์ด ํ๋ก์ ํธ ์ค์น ๋ฐ ์คํ ๋ฐฉ๋ฒ์ด ๋ฌธ์ํ๋์ด ์์ต๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!