Compare commits
10 Commits
b02a18d762
...
6877ef30b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 6877ef30b5 | |||
| 03f8caf3e9 | |||
| 4baa4cb52b | |||
| 81a9f84b26 | |||
| 975b56ca54 | |||
| ad2c05779d | |||
| da48a3652d | |||
| 550e2fc97a | |||
| 725c86f434 | |||
| 9de698d90e |
+2
-1
@@ -4,6 +4,7 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -46,7 +47,7 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
|
||||
@@ -17,3 +17,11 @@ yarn-error.log
|
||||
/.fleet
|
||||
/.idea
|
||||
/.vscode
|
||||
/.claude
|
||||
/.opensp
|
||||
|
||||
# Frontend
|
||||
/frontend/node_modules
|
||||
/frontend/dist
|
||||
/frontend/.env
|
||||
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# 使用官方 PHP 8.2 FPM 鏡像作為基礎
|
||||
FROM php:8.2-fpm
|
||||
|
||||
# 安裝系統依賴
|
||||
# 1. 更新套件列表並安裝必要的套件
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
libpng-dev \
|
||||
libonig-dev \
|
||||
libxml2-dev \
|
||||
zip \
|
||||
unzip \
|
||||
libzip-dev \
|
||||
default-mysql-client \
|
||||
netcat-openbsd \
|
||||
grep \
|
||||
cron
|
||||
|
||||
# 清理 apt 快取以減小鏡像大小
|
||||
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安裝 PHP 擴展
|
||||
# 這些擴展是 Laravel 和一般 PHP 開發所需的
|
||||
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd zip
|
||||
|
||||
# 從官方 Composer 鏡像複製 Composer 執行文件
|
||||
# 這樣可以確保我們使用最新版本的 Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# 設置工作目錄
|
||||
WORKDIR /var/www
|
||||
|
||||
# 設置目錄權限
|
||||
# 將 /var/www 目錄的所有權更改為 www-data 用戶和組
|
||||
RUN chown -R www-data:www-data /var/www
|
||||
|
||||
# 創建必要的目錄並設置權限
|
||||
# Laravel 需要這些目錄來存儲日誌、緩存等
|
||||
RUN mkdir -p /var/www/storage /var/www/bootstrap/cache
|
||||
RUN chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||
|
||||
# 複製自定義的 PHP 配置文件
|
||||
# 這個文件包含 PHP 運行時的配置選項
|
||||
COPY docker/php/local.ini /usr/local/etc/php/conf.d/local.ini
|
||||
|
||||
# 複製並設置入口點腳本
|
||||
# 這個腳本將在容器啟動時執行
|
||||
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# 加入 Laravel Scheduler cron job
|
||||
RUN echo "* * * * * www-data php /var/www/artisan schedule:run >> /var/log/laravel-scheduler.log 2>&1" \
|
||||
> /etc/cron.d/laravel-scheduler \
|
||||
&& chmod 0644 /etc/cron.d/laravel-scheduler \
|
||||
&& crontab /etc/cron.d/laravel-scheduler
|
||||
|
||||
# 設置容器啟動時執行的入口點
|
||||
# 這將在 CMD 指令之前執行
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# 設置默認的容器命令
|
||||
# 這將在 ENTRYPOINT 執行後運行
|
||||
CMD ["php-fpm"]
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Models\Booking;
|
||||
use App\Notifications\BookingCompletedNotification;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CompleteFinishedBookings extends Command
|
||||
{
|
||||
protected $signature = 'app:complete-finished-bookings';
|
||||
protected $description = '將課程日期已過的 confirmed 預約標記為 completed';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$bookings = Booking::with(['member', 'schedule.divingOffer'])
|
||||
->where('status', BookingStatus::Confirmed->value)
|
||||
->whereHas('schedule', fn($q) => $q->whereDate('scheduled_date', '<', now()->toDateString()))
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($bookings as $booking) {
|
||||
$booking->update(['status' => BookingStatus::Completed]);
|
||||
$count++;
|
||||
|
||||
try {
|
||||
$booking->member->notify(new BookingCompletedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("BookingCompletedNotification failed for booking #{$booking->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("CompleteFinishedBookings: {$count} completed");
|
||||
$this->info("CompleteFinishedBookings: {$count} bookings completed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ExpirePendingBookings extends Command
|
||||
{
|
||||
protected $signature = 'app:expire-pending-bookings';
|
||||
protected $description = '將超過 48 小時未確認的 pending 預約標記為 expired';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$count = Booking::where('status', BookingStatus::Pending->value)
|
||||
->where('created_at', '<=', now()->subHours(48))
|
||||
->update(['status' => BookingStatus::Expired->value]);
|
||||
|
||||
Log::info("ExpirePendingBookings: {$count} expired");
|
||||
$this->info("ExpirePendingBookings: {$count} bookings expired.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BookingStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Confirmed = 'confirmed';
|
||||
case Completed = 'completed';
|
||||
case Rejected = 'rejected';
|
||||
case Expired = 'expired';
|
||||
case MemberCancelled = 'member_cancelled';
|
||||
case ProviderCancelled = 'provider_cancelled';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ScheduleStatus: string
|
||||
{
|
||||
case Open = 'open';
|
||||
case Full = 'full';
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
|
||||
class AdminBookingController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$bookings = Booking::with(['member', 'schedule.divingOffer'])
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => [
|
||||
'id' => $b->id,
|
||||
'member_name' => $b->member?->name,
|
||||
'member_email' => $b->member?->email,
|
||||
'offer_title' => $b->schedule?->divingOffer?->title,
|
||||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $bookings]);
|
||||
}
|
||||
|
||||
public function complete(int $id)
|
||||
{
|
||||
$booking = Booking::findOrFail($id);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
|
||||
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Completed]);
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminOfferController extends Controller
|
||||
{
|
||||
private function checkAdmin()
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$query = DivingOffer::query();
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('title', 'like', "%{$q}%")
|
||||
->orWhere('location', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$paginated = $query->latest('id')->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $paginated->items(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$offer = DivingOffer::find($id);
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
$offer->delete();
|
||||
return response()->json(['status' => true, 'message' => '課程已刪除']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\Review;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AdminReviewController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$reviews = Review::with(['divingOffer', 'member'])
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($r) => [
|
||||
'id' => $r->id,
|
||||
'offer_title' => $r->divingOffer?->title,
|
||||
'member_email'=> $r->member?->email,
|
||||
'rating' => $r->rating,
|
||||
'comment' => mb_strimwidth($r->comment, 0, 50, '...'),
|
||||
'is_edited' => $r->is_edited,
|
||||
'helpful_count'=> $r->helpful_count,
|
||||
'created_at' => $r->created_at?->toISOString(),
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $reviews]);
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$review = Review::findOrFail($id);
|
||||
$offerId = $review->diving_offer_id;
|
||||
|
||||
DB::transaction(function () use ($review, $offerId) {
|
||||
$review->delete();
|
||||
$this->recalculateOfferRating($offerId);
|
||||
});
|
||||
|
||||
return response()->json(['status' => true, 'message' => '評價已刪除']);
|
||||
}
|
||||
|
||||
private function recalculateOfferRating(int $offerId): void
|
||||
{
|
||||
$stats = Review::where('diving_offer_id', $offerId)
|
||||
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
|
||||
->first();
|
||||
|
||||
DivingOffer::where('id', $offerId)->update([
|
||||
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
|
||||
'reviews' => $stats->total,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\User;
|
||||
|
||||
class AdminStatsController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => [
|
||||
'total_members' => User::where('role', 'member')->count(),
|
||||
'total_providers' => User::where('role', 'provider')->count(),
|
||||
'total_offers' => DivingOffer::count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminUserController extends Controller
|
||||
{
|
||||
private function checkAdmin()
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findUser(int $id, string $role)
|
||||
{
|
||||
return User::where('id', $id)->where('role', $role)->first();
|
||||
}
|
||||
|
||||
public function members(Request $request)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$query = User::where('role', 'member')->with('memberProfile');
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('name', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$paginated = $query->latest()->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $paginated->items(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function member(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'member');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $user->load('memberProfile')]);
|
||||
}
|
||||
|
||||
public function toggleMemberActive(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'member');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
$user->is_active = !$user->is_active;
|
||||
$user->save();
|
||||
|
||||
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
|
||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||
}
|
||||
|
||||
public function providers(Request $request)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$query = User::where('role', 'provider')->with('providerProfile');
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('name', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$paginated = $query->latest()->paginate(15);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $paginated->items(),
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function provider(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'provider');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $user->load('providerProfile')]);
|
||||
}
|
||||
|
||||
public function toggleProviderActive(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'provider');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
$user->is_active = !$user->is_active;
|
||||
$user->save();
|
||||
|
||||
$msg = $user->is_active ? '帳號已啟用' : '帳號已停用';
|
||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_active' => $user->is_active]]);
|
||||
}
|
||||
|
||||
public function toggleProviderVerified(int $id)
|
||||
{
|
||||
if ($err = $this->checkAdmin()) return $err;
|
||||
|
||||
$user = $this->findUser($id, 'provider');
|
||||
if (!$user) {
|
||||
return response()->json(['status' => false, 'message' => '用戶不存在'], 404);
|
||||
}
|
||||
|
||||
$profile = $user->providerProfile;
|
||||
$profile->is_verified = !$profile->is_verified;
|
||||
$profile->save();
|
||||
|
||||
$msg = $profile->is_verified ? '教練已驗證' : '已取消驗證';
|
||||
return response()->json(['status' => true, 'message' => $msg, 'data' => ['is_verified' => $profile->is_verified]]);
|
||||
}
|
||||
}
|
||||
@@ -474,7 +474,7 @@ class AuthController extends Controller
|
||||
'email' => 'required|string|email|max:255|unique:users',
|
||||
'password' => 'required|string|min:6|confirmed',
|
||||
'phone' => 'nullable|string|max:20',
|
||||
'business_name' => 'required|string|max:255',
|
||||
'business_name' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'contact_person' => 'nullable|string|max:100',
|
||||
'contact_phone' => 'nullable|string|max:20',
|
||||
@@ -655,6 +655,12 @@ class AuthController extends Controller
|
||||
'contact_email' => 'nullable|string|email|max:255',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'business_hours' => 'nullable|string|max:100',
|
||||
'certifications' => 'nullable|string',
|
||||
'dive_sites' => 'nullable|string',
|
||||
'services' => 'nullable|string',
|
||||
'facilities' => 'nullable|string',
|
||||
'website' => 'nullable|string|max:255',
|
||||
'social_media' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -701,7 +707,25 @@ class AuthController extends Controller
|
||||
if ($request->has('business_hours')) {
|
||||
$providerProfile->business_hours = $request->business_hours;
|
||||
}
|
||||
|
||||
if ($request->has('certifications')) {
|
||||
$providerProfile->certifications = $request->certifications;
|
||||
}
|
||||
if ($request->has('dive_sites')) {
|
||||
$providerProfile->dive_sites = $request->dive_sites;
|
||||
}
|
||||
if ($request->has('services')) {
|
||||
$providerProfile->services = $request->services;
|
||||
}
|
||||
if ($request->has('facilities')) {
|
||||
$providerProfile->facilities = $request->facilities;
|
||||
}
|
||||
if ($request->has('website')) {
|
||||
$providerProfile->website = $request->website;
|
||||
}
|
||||
if ($request->has('social_media')) {
|
||||
$providerProfile->social_media = $request->social_media;
|
||||
}
|
||||
|
||||
$providerProfile->save();
|
||||
|
||||
// 加載服務提供者資料
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CourseImage;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CourseImageController extends Controller
|
||||
{
|
||||
private function validateImage(Request $request): void
|
||||
{
|
||||
$request->validate([
|
||||
'image' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeOffer(Request $request, DivingOffer $offer): void
|
||||
{
|
||||
if ($offer->provider_id !== $request->user()->id) {
|
||||
abort(403, '無權操作此課程');
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadCover(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
$this->validateImage($request);
|
||||
|
||||
if ($offer->cover_image) {
|
||||
Storage::disk('public')->delete($offer->cover_image);
|
||||
}
|
||||
|
||||
$path = $request->file('image')->store("offers/{$offerId}/cover", 'public');
|
||||
$offer->update(['cover_image' => $path]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '封面已上傳',
|
||||
'cover_image_url' => $offer->cover_image_url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteCover(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
|
||||
if ($offer->cover_image) {
|
||||
Storage::disk('public')->delete($offer->cover_image);
|
||||
$offer->update(['cover_image' => null]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '封面已刪除']);
|
||||
}
|
||||
|
||||
public function uploadImage(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$this->authorizeOffer($request, $offer);
|
||||
$this->validateImage($request);
|
||||
|
||||
if ($offer->courseImages()->count() >= 3) {
|
||||
return response()->json(['status' => false, 'message' => '相簿最多 3 張圖片'], 422);
|
||||
}
|
||||
|
||||
$path = $request->file('image')->store("offers/{$offerId}/gallery", 'public');
|
||||
$sortOrder = ($offer->courseImages()->max('sort_order') ?? 0) + 1;
|
||||
|
||||
$image = CourseImage::create([
|
||||
'diving_offer_id' => $offerId,
|
||||
'image_path' => $path,
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '圖片已上傳',
|
||||
'data' => ['id' => $image->id, 'url' => $image->url, 'sort_order' => $image->sort_order],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function deleteImage(Request $request, int $imageId)
|
||||
{
|
||||
$image = CourseImage::with('divingOffer')->findOrFail($imageId);
|
||||
|
||||
if ($image->divingOffer->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權刪除此圖片'], 403);
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($image->image_path);
|
||||
$image->delete();
|
||||
|
||||
return response()->json(['status' => true, 'message' => '圖片已刪除']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DivingOfferController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = min((int) $request->query('per_page', 12), 50);
|
||||
|
||||
$query = DivingOffer::query();
|
||||
|
||||
if ($q = $request->query('q')) {
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('title', 'like', "%{$q}%")
|
||||
->orWhere('location', 'like', "%{$q}%")
|
||||
->orWhere('spot', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($region = $request->query('region')) {
|
||||
$query->where('region', $region);
|
||||
}
|
||||
|
||||
if ($tag = $request->query('tag')) {
|
||||
$query->where('tag', 'like', "%{$tag}%");
|
||||
}
|
||||
|
||||
$paginated = $query->paginate($perPage);
|
||||
|
||||
$items = collect($paginated->items())->map(fn($o) => $this->formatOffer($o, false));
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
$offer = DivingOffer::with('courseImages')->find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $this->formatOffer($offer, true)]);
|
||||
}
|
||||
|
||||
private function formatOffer(DivingOffer $offer, bool $withImages): array
|
||||
{
|
||||
$data = array_merge($offer->toArray(), [
|
||||
'cover_image_url' => $offer->cover_image_url,
|
||||
]);
|
||||
|
||||
if ($withImages) {
|
||||
$data['images'] = $offer->courseImages->map(fn($img) => [
|
||||
'id' => $img->id,
|
||||
'url' => $img->url,
|
||||
'sort_order' => $img->sort_order,
|
||||
])->values();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Notifications\BookingCreatedNotification;
|
||||
use App\Notifications\BookingCancelledNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MemberBookingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$bookings = Booking::with(['schedule.divingOffer'])
|
||||
->where('member_id', $request->user()->id)
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => $this->formatBooking($b));
|
||||
|
||||
return response()->json(['status' => true, 'data' => $bookings]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with(['schedule.divingOffer'])->findOrFail($id);
|
||||
if ($booking->member_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權查看此預約'], 403);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $this->formatBooking($booking)]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'schedule_id' => 'required|integer|exists:course_schedules,id',
|
||||
'participants' => 'required|integer|min:1',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$schedule = CourseSchedule::with('divingOffer')->findOrFail($data['schedule_id']);
|
||||
|
||||
// Layer 1:快速失敗
|
||||
if ($schedule->status !== ScheduleStatus::Open) {
|
||||
return response()->json(['status' => false, 'message' => '此時段不開放預約'], 422);
|
||||
}
|
||||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||||
return response()->json(['status' => false, 'message' => '人數超過剩餘名額'], 422);
|
||||
}
|
||||
|
||||
$memberId = $request->user()->id;
|
||||
|
||||
try {
|
||||
$booking = DB::transaction(function () use ($data, $schedule, $memberId) {
|
||||
// Layer 2:lockForUpdate 後二次驗證
|
||||
$schedule = CourseSchedule::lockForUpdate()->find($schedule->id);
|
||||
if ($data['participants'] > $schedule->remainingSpots()) {
|
||||
throw new \RuntimeException('名額不足,請重新選擇');
|
||||
}
|
||||
|
||||
// 重複預約檢查
|
||||
$duplicate = Booking::where('member_id', $memberId)
|
||||
->where('schedule_id', $schedule->id)
|
||||
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
throw new \RuntimeException('您已預約此時段');
|
||||
}
|
||||
|
||||
return Booking::create([
|
||||
'schedule_id' => $schedule->id,
|
||||
'member_id' => $memberId,
|
||||
'participants' => $data['participants'],
|
||||
'total_price' => $schedule->divingOffer->price * $data['participants'],
|
||||
'status' => BookingStatus::Pending,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
});
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$booking->load('schedule.divingOffer.provider');
|
||||
$provider = $booking->schedule->divingOffer->provider;
|
||||
$provider->notify(new BookingCreatedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCreatedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => '預約已送出,等待教練確認',
|
||||
'data' => $this->formatBooking($booking->fresh(['schedule.divingOffer'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
if ($booking->member_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權操作此預約'], 403);
|
||||
}
|
||||
|
||||
$canCancelFrom = [BookingStatus::Pending, BookingStatus::Confirmed];
|
||||
if (!in_array($booking->status, $canCancelFrom)) {
|
||||
return response()->json(['status' => false, 'message' => '此預約狀態無法取消'], 422);
|
||||
}
|
||||
|
||||
// 24h 截止驗證
|
||||
$schedule = $booking->schedule;
|
||||
$courseStart = Carbon::parse($schedule->scheduled_date->toDateString() . ' ' . $schedule->start_time);
|
||||
if (now()->diffInHours($courseStart, false) < 24) {
|
||||
return response()->json(['status' => false, 'message' => '距課程開始不足 24 小時,無法取消,請聯繫教練'], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($booking, $schedule) {
|
||||
$wasConfirmed = $booking->status === BookingStatus::Confirmed;
|
||||
$booking->update(['status' => BookingStatus::MemberCancelled]);
|
||||
|
||||
if ($wasConfirmed) {
|
||||
$schedule = $booking->schedule()->lockForUpdate()->first();
|
||||
$schedule->decrement('current_participants', $booking->participants);
|
||||
$schedule->refresh();
|
||||
|
||||
if ($schedule->current_participants < $schedule->max_participants
|
||||
&& $schedule->status === ScheduleStatus::Full) {
|
||||
$schedule->update(['status' => ScheduleStatus::Open]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$booking->load('schedule.divingOffer.provider');
|
||||
$provider = $booking->schedule->divingOffer->provider;
|
||||
$provider->notify(new BookingCancelledNotification($booking, cancelledBy: 'member'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCancelledNotification(member) failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已取消']);
|
||||
}
|
||||
|
||||
private function formatBooking(Booking $b): array
|
||||
{
|
||||
$offer = $b->schedule?->divingOffer;
|
||||
return [
|
||||
'id' => $b->id,
|
||||
'offer_id' => $offer?->id,
|
||||
'offer_title' => $offer?->title,
|
||||
'offer_location' => $offer?->location,
|
||||
'offer_region' => $offer?->region,
|
||||
'offer_price' => $offer?->price,
|
||||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'notes' => $b->notes,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$user = $request->user();
|
||||
$unreadCount = $user->unreadNotifications()->count();
|
||||
$notifications = $user->notifications()
|
||||
->orderByDesc('created_at')
|
||||
->paginate(20);
|
||||
|
||||
$items = $notifications->map(fn($n) => array_merge($n->data, [
|
||||
'id' => $n->id,
|
||||
'read_at' => $n->read_at?->toISOString(),
|
||||
'created_at' => $n->created_at->toISOString(),
|
||||
]));
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $items,
|
||||
'unread_count' => $unreadCount,
|
||||
'meta' => [
|
||||
'current_page' => $notifications->currentPage(),
|
||||
'last_page' => $notifications->lastPage(),
|
||||
'total' => $notifications->total(),
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['status' => true, 'data' => [], 'unread_count' => 0, 'meta' => ['current_page' => 1, 'last_page' => 1, 'total' => 0]]);
|
||||
}
|
||||
}
|
||||
|
||||
public function unreadCount(Request $request)
|
||||
{
|
||||
try {
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => ['count' => $request->user()->unreadNotifications()->count()],
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['status' => true, 'data' => ['count' => 0]]);
|
||||
}
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $id)
|
||||
{
|
||||
$notification = $request->user()->notifications()->findOrFail($id);
|
||||
$notification->markAsRead();
|
||||
|
||||
return response()->json(['status' => true]);
|
||||
}
|
||||
|
||||
public function markAllRead(Request $request)
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return response()->json(['status' => true]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $id)
|
||||
{
|
||||
$notification = $request->user()->notifications()->findOrFail($id);
|
||||
$notification->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Notifications\BookingCancelledNotification;
|
||||
use App\Notifications\BookingCompletedNotification;
|
||||
use App\Notifications\BookingConfirmedNotification;
|
||||
use App\Notifications\BookingRejectedNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProviderBookingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$provider = $request->user();
|
||||
$bookings = Booking::with(['member', 'schedule.divingOffer'])
|
||||
->whereHas('schedule', fn($q) => $q->where('provider_id', $provider->id))
|
||||
->orderByDesc('created_at')
|
||||
->get()
|
||||
->map(fn($b) => $this->formatBooking($b));
|
||||
|
||||
return response()->json(['status' => true, 'data' => $bookings]);
|
||||
}
|
||||
|
||||
public function confirm(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Confirmed)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法確認'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($booking) {
|
||||
$schedule = $booking->schedule()->lockForUpdate()->first();
|
||||
$remaining = $schedule->max_participants - $schedule->current_participants;
|
||||
|
||||
if ($booking->participants > $remaining) {
|
||||
throw new \RuntimeException('名額不足,無法確認此預約');
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Confirmed]);
|
||||
$schedule->increment('current_participants', $booking->participants);
|
||||
$schedule->refresh();
|
||||
|
||||
if ($schedule->current_participants >= $schedule->max_participants) {
|
||||
$schedule->update(['status' => ScheduleStatus::Full]);
|
||||
}
|
||||
});
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['status' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingConfirmedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingConfirmedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已確認', 'data' => $this->formatBooking($booking->fresh(['member', 'schedule.divingOffer']))]);
|
||||
}
|
||||
|
||||
public function reject(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Rejected)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法拒絕'], 422);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Rejected]);
|
||||
|
||||
try {
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingRejectedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingRejectedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已拒絕']);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::ProviderCancelled)) {
|
||||
return response()->json(['status' => false, 'message' => '當前狀態無法取消'], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($booking) {
|
||||
$schedule = $booking->schedule()->lockForUpdate()->first();
|
||||
$booking->update(['status' => BookingStatus::ProviderCancelled]);
|
||||
$schedule->decrement('current_participants', $booking->participants);
|
||||
$schedule->refresh();
|
||||
|
||||
if ($schedule->current_participants < $schedule->max_participants
|
||||
&& $schedule->status === ScheduleStatus::Full) {
|
||||
$schedule->update(['status' => ScheduleStatus::Open]);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingCancelledNotification($booking, cancelledBy: 'provider'));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCancelledNotification(provider) failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已取消']);
|
||||
}
|
||||
|
||||
public function complete(Request $request, int $id)
|
||||
{
|
||||
$booking = Booking::with('schedule')->findOrFail($id);
|
||||
$this->authorizeProvider($request, $booking);
|
||||
|
||||
if (!$booking->canTransitionTo(BookingStatus::Completed)) {
|
||||
return response()->json(['status' => false, 'message' => '只有已確認的預約才能標記完成'], 422);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Completed]);
|
||||
|
||||
try {
|
||||
$booking->load('member');
|
||||
$booking->member->notify(new BookingCompletedNotification($booking));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('BookingCompletedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '預約已標記為完成']);
|
||||
}
|
||||
|
||||
private function authorizeProvider(Request $request, Booking $booking): void
|
||||
{
|
||||
if ($booking->schedule->provider_id !== $request->user()->id) {
|
||||
abort(403, '無權操作此預約');
|
||||
}
|
||||
}
|
||||
|
||||
private function formatBooking(Booking $b): array
|
||||
{
|
||||
return [
|
||||
'id' => $b->id,
|
||||
'member_name' => $b->member?->name,
|
||||
'member_email' => $b->member?->email,
|
||||
'offer_title' => $b->schedule?->divingOffer?->title,
|
||||
'scheduled_date' => $b->schedule?->scheduled_date?->toDateString(),
|
||||
'start_time' => $b->schedule?->start_time,
|
||||
'participants' => $b->participants,
|
||||
'total_price' => $b->total_price,
|
||||
'status' => $b->status->value,
|
||||
'notes' => $b->notes,
|
||||
'created_at' => $b->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProviderOfferController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$offers = DivingOffer::where('provider_id', auth()->id())
|
||||
->paginate(12);
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => $offers->items(),
|
||||
'meta' => [
|
||||
'total' => $offers->total(),
|
||||
'per_page' => $offers->perPage(),
|
||||
'current_page' => $offers->currentPage(),
|
||||
'last_page' => $offers->lastPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $id)
|
||||
{
|
||||
$offer = DivingOffer::find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
if ($offer->provider_id !== auth()->id()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限查看此課程'], 403);
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'data' => $offer]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'location' => 'required|string|max:255',
|
||||
'spot' => 'nullable|string|max:255',
|
||||
'price' => 'required|integer|min:0',
|
||||
'region' => 'required|string|max:100',
|
||||
'tag' => 'nullable|string|max:100',
|
||||
'badges' => 'nullable|array',
|
||||
'badges.*' => 'string|max:50',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$validated['provider_id'] = auth()->id();
|
||||
$validated['rating'] = 0;
|
||||
$validated['reviews'] = 0;
|
||||
|
||||
$offer = DivingOffer::create($validated);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $offer], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$offer = DivingOffer::find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
if ($offer->provider_id !== auth()->id()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限修改此課程'], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'nullable|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'spot' => 'nullable|string|max:255',
|
||||
'price' => 'nullable|integer|min:0',
|
||||
'region' => 'nullable|string|max:100',
|
||||
'tag' => 'nullable|string|max:100',
|
||||
'badges' => 'nullable|array',
|
||||
'badges.*' => 'string|max:50',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$offer->fill($validated)->save();
|
||||
|
||||
return response()->json(['status' => true, 'data' => $offer]);
|
||||
}
|
||||
|
||||
public function destroy(int $id)
|
||||
{
|
||||
$offer = DivingOffer::find($id);
|
||||
|
||||
if (!$offer) {
|
||||
return response()->json(['status' => false, 'message' => '課程不存在'], 404);
|
||||
}
|
||||
|
||||
if ($offer->provider_id !== auth()->id()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限刪除此課程'], 403);
|
||||
}
|
||||
|
||||
$offer->delete();
|
||||
|
||||
return response()->json(['status' => true, 'message' => '課程已刪除']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Booking;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\Review;
|
||||
use App\Models\ReviewEdit;
|
||||
use App\Models\ReviewVote;
|
||||
use App\Notifications\ReviewReceivedNotification;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ReviewController extends Controller
|
||||
{
|
||||
// ── 公開列表 ──────────────────────────────────────────────
|
||||
|
||||
public function publicList(Request $request, int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$user = $request->user();
|
||||
|
||||
$sort = $request->query('sort', 'helpful');
|
||||
$query = Review::where('diving_offer_id', $offer->id);
|
||||
|
||||
match ($sort) {
|
||||
'rating' => $query->orderByDesc('rating')->orderByDesc('created_at'),
|
||||
'newest' => $query->orderByDesc('created_at'),
|
||||
default => $query->orderByDesc('helpful_count')->orderByDesc('created_at'),
|
||||
};
|
||||
|
||||
$reviews = $query->get();
|
||||
|
||||
// 批次查詢 has_voted
|
||||
$votedIds = $user
|
||||
? ReviewVote::where('member_id', $user->id)
|
||||
->whereIn('review_id', $reviews->pluck('id'))
|
||||
->pluck('review_id')
|
||||
->flip()
|
||||
: collect();
|
||||
|
||||
// summary
|
||||
$distRaw = Review::where('diving_offer_id', $offer->id)
|
||||
->selectRaw('rating, COUNT(*) as cnt')
|
||||
->groupBy('rating')
|
||||
->pluck('cnt', 'rating');
|
||||
$distribution = collect([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0])->merge($distRaw);
|
||||
|
||||
$total = $reviews->count();
|
||||
$average = $total > 0 ? round($reviews->avg('rating'), 1) : 0;
|
||||
|
||||
$formatted = $reviews->map(function ($r) use ($user, $votedIds) {
|
||||
$item = [
|
||||
'id' => $r->id,
|
||||
'reviewer_name' => '匿名潛水者',
|
||||
'rating' => $r->rating,
|
||||
'comment' => $r->comment,
|
||||
'helpful_count' => $r->helpful_count,
|
||||
'is_edited' => $r->is_edited,
|
||||
'created_at' => $r->created_at?->toISOString(),
|
||||
'has_voted' => $votedIds->has($r->id),
|
||||
];
|
||||
if ($user) {
|
||||
$item['is_mine'] = $r->member_id === $user->id;
|
||||
}
|
||||
return $item;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'data' => [
|
||||
'summary' => [
|
||||
'average' => $average,
|
||||
'total' => $total,
|
||||
'distribution' => $distribution,
|
||||
],
|
||||
'reviews' => $formatted,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Member CRUD ───────────────────────────────────────────
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
|
||||
'rating' => 'required|integer|min:1|max:5',
|
||||
'comment' => 'required|string|min:1',
|
||||
]);
|
||||
|
||||
$memberId = $request->user()->id;
|
||||
$offerId = $data['diving_offer_id'];
|
||||
|
||||
// 資格驗證:有 completed booking
|
||||
$eligible = Booking::where('member_id', $memberId)
|
||||
->whereHas('schedule', fn($q) => $q->where('diving_offer_id', $offerId))
|
||||
->where('status', BookingStatus::Completed->value)
|
||||
->exists();
|
||||
|
||||
if (!$eligible) {
|
||||
return response()->json(['status' => false, 'message' => '須完成此課程後才能評價'], 403);
|
||||
}
|
||||
|
||||
// 重複評價檢查
|
||||
if (Review::where('member_id', $memberId)->where('diving_offer_id', $offerId)->exists()) {
|
||||
return response()->json(['status' => false, 'message' => '已評價,如需修改請使用編輯功能'], 422);
|
||||
}
|
||||
|
||||
$review = DB::transaction(function () use ($data, $memberId, $offerId) {
|
||||
$review = Review::create([
|
||||
'diving_offer_id' => $offerId,
|
||||
'member_id' => $memberId,
|
||||
'rating' => $data['rating'],
|
||||
'comment' => $data['comment'],
|
||||
]);
|
||||
$this->recalculateOfferRating($offerId);
|
||||
return $review;
|
||||
});
|
||||
|
||||
try {
|
||||
$offer = DivingOffer::with('provider')->findOrFail($offerId);
|
||||
$provider = $offer->provider;
|
||||
if ($provider) {
|
||||
$provider->notify(new ReviewReceivedNotification($review));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ReviewReceivedNotification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json(['status' => true, 'message' => '評價已送出', 'data' => $this->formatReview($review)], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$review = Review::findOrFail($id);
|
||||
if ($review->member_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權修改此評價'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'rating' => 'sometimes|integer|min:1|max:5',
|
||||
'comment' => 'sometimes|string|min:1',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($review, $data) {
|
||||
ReviewEdit::updateOrCreate(
|
||||
['review_id' => $review->id],
|
||||
['old_rating' => $review->rating, 'old_comment' => $review->comment, 'edited_at' => now()]
|
||||
);
|
||||
$review->update(array_merge($data, ['is_edited' => true]));
|
||||
$this->recalculateOfferRating($review->diving_offer_id);
|
||||
});
|
||||
|
||||
return response()->json(['status' => true, 'message' => '評價已更新', 'data' => $this->formatReview($review->fresh())]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$review = Review::findOrFail($id);
|
||||
if ($review->member_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權刪除此評價'], 403);
|
||||
}
|
||||
|
||||
$offerId = $review->diving_offer_id;
|
||||
DB::transaction(function () use ($review, $offerId) {
|
||||
$review->delete();
|
||||
$this->recalculateOfferRating($offerId);
|
||||
});
|
||||
|
||||
return response()->json(['status' => true, 'message' => '評價已刪除']);
|
||||
}
|
||||
|
||||
// ── 有幫助投票 ────────────────────────────────────────────
|
||||
|
||||
public function toggleHelpful(Request $request, int $id)
|
||||
{
|
||||
if (!$request->user()->isMember()) {
|
||||
return response()->json(['status' => false, 'message' => '只有會員可以投票'], 403);
|
||||
}
|
||||
|
||||
$review = Review::findOrFail($id);
|
||||
$memberId = $request->user()->id;
|
||||
|
||||
if ($review->member_id === $memberId) {
|
||||
return response()->json(['status' => false, 'message' => '不可對自己的評價投票'], 422);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($review, $memberId) {
|
||||
$vote = ReviewVote::where('review_id', $review->id)
|
||||
->where('member_id', $memberId)
|
||||
->first();
|
||||
|
||||
if ($vote) {
|
||||
$vote->delete();
|
||||
DB::table('reviews')
|
||||
->where('id', $review->id)
|
||||
->where('helpful_count', '>', 0)
|
||||
->decrement('helpful_count');
|
||||
} else {
|
||||
ReviewVote::create(['review_id' => $review->id, 'member_id' => $memberId, 'created_at' => now()]);
|
||||
$review->increment('helpful_count');
|
||||
}
|
||||
});
|
||||
|
||||
$review->refresh();
|
||||
$hasVoted = ReviewVote::where('review_id', $review->id)->where('member_id', $memberId)->exists();
|
||||
|
||||
return response()->json(['status' => true, 'data' => ['helpful_count' => $review->helpful_count, 'has_voted' => $hasVoted]]);
|
||||
}
|
||||
|
||||
// ── 私有方法 ──────────────────────────────────────────────
|
||||
|
||||
private function recalculateOfferRating(int $offerId): void
|
||||
{
|
||||
$stats = Review::where('diving_offer_id', $offerId)
|
||||
->selectRaw('ROUND(AVG(rating), 1) as avg_rating, COUNT(*) as total')
|
||||
->first();
|
||||
|
||||
DivingOffer::where('id', $offerId)->update([
|
||||
'rating' => $stats->total > 0 ? $stats->avg_rating : 0,
|
||||
'reviews' => $stats->total,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatReview(Review $r): array
|
||||
{
|
||||
return [
|
||||
'id' => $r->id,
|
||||
'reviewer_name' => '匿名潛水者',
|
||||
'rating' => $r->rating,
|
||||
'comment' => $r->comment,
|
||||
'helpful_count' => $r->helpful_count,
|
||||
'is_edited' => $r->is_edited,
|
||||
'created_at' => $r->created_at?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Models\DivingOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ScheduleController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$provider = $request->user();
|
||||
$schedules = CourseSchedule::with('divingOffer')
|
||||
->where('provider_id', $provider->id)
|
||||
->orderBy('scheduled_date')
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->map(fn($s) => $this->formatSchedule($s));
|
||||
|
||||
return response()->json(['status' => true, 'data' => $schedules]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'diving_offer_id' => 'required|integer|exists:diving_offers,id',
|
||||
'scheduled_date' => 'required|date|after_or_equal:today',
|
||||
'start_time' => 'required|date_format:H:i',
|
||||
'max_participants' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$offer = DivingOffer::findOrFail($data['diving_offer_id']);
|
||||
if ($offer->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權操作此課程'], 403);
|
||||
}
|
||||
|
||||
$schedule = CourseSchedule::create([
|
||||
'diving_offer_id' => $data['diving_offer_id'],
|
||||
'provider_id' => $request->user()->id,
|
||||
'scheduled_date' => $data['scheduled_date'],
|
||||
'start_time' => $data['start_time'],
|
||||
'max_participants' => $data['max_participants'],
|
||||
'current_participants'=> 0,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule)], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$schedule = CourseSchedule::findOrFail($id);
|
||||
if ($schedule->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'start_time' => 'sometimes|date_format:H:i',
|
||||
'max_participants' => 'sometimes|integer|min:1',
|
||||
]);
|
||||
|
||||
if (isset($data['max_participants']) && $data['max_participants'] < $schedule->current_participants) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => '人數上限不可低於目前已確認人數(' . $schedule->current_participants . ')',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$schedule->update($data);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $this->formatSchedule($schedule->fresh())]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$schedule = CourseSchedule::findOrFail($id);
|
||||
if ($schedule->provider_id !== $request->user()->id) {
|
||||
return response()->json(['status' => false, 'message' => '無權操作此時段'], 403);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($schedule) {
|
||||
$schedule->update(['status' => ScheduleStatus::Cancelled]);
|
||||
$schedule->bookings()
|
||||
->whereIn('status', [BookingStatus::Pending->value, BookingStatus::Confirmed->value])
|
||||
->update(['status' => BookingStatus::ProviderCancelled->value]);
|
||||
});
|
||||
|
||||
return response()->json(['status' => true, 'message' => '時段已取消']);
|
||||
}
|
||||
|
||||
public function publicList(int $offerId)
|
||||
{
|
||||
$offer = DivingOffer::findOrFail($offerId);
|
||||
$schedules = CourseSchedule::where('diving_offer_id', $offer->id)
|
||||
->where('status', ScheduleStatus::Open->value)
|
||||
->whereDate('scheduled_date', '>=', now()->toDateString())
|
||||
->orderBy('scheduled_date')
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->map(fn($s) => [
|
||||
'id' => $s->id,
|
||||
'scheduled_date' => $s->scheduled_date->toDateString(),
|
||||
'start_time' => $s->start_time,
|
||||
'max_participants' => $s->max_participants,
|
||||
'remaining_spots' => $s->remainingSpots(),
|
||||
'status' => $s->status->value,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => true, 'data' => $schedules]);
|
||||
}
|
||||
|
||||
private function formatSchedule(CourseSchedule $s): array
|
||||
{
|
||||
return [
|
||||
'id' => $s->id,
|
||||
'diving_offer_id' => $s->diving_offer_id,
|
||||
'offer_title' => $s->divingOffer?->title,
|
||||
'scheduled_date' => $s->scheduled_date?->toDateString(),
|
||||
'start_time' => $s->start_time,
|
||||
'max_participants' => $s->max_participants,
|
||||
'current_participants' => $s->current_participants,
|
||||
'remaining_spots' => $s->remainingSpots(),
|
||||
'status' => $s->status->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -105,24 +105,10 @@ class SocialAuthController extends Controller
|
||||
|
||||
// 生成 Sanctum token
|
||||
$token = $user->createToken('google-auth')->plainTextToken;
|
||||
|
||||
// 載入會員資料
|
||||
$user->load('memberProfile');
|
||||
|
||||
return response()->json([
|
||||
'status' => true,
|
||||
'message' => 'Google 登入成功',
|
||||
'data' => [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
]
|
||||
]);
|
||||
|
||||
return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Google 登入失敗:' . $e->getMessage()
|
||||
], 500);
|
||||
return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$request->user() || !$request->user()->isAdmin()) {
|
||||
return response()->json(['status' => false, 'message' => '無權限存取'], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Booking extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'schedule_id',
|
||||
'member_id',
|
||||
'participants',
|
||||
'total_price',
|
||||
'status',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'participants' => 'integer',
|
||||
'total_price' => 'integer',
|
||||
'status' => BookingStatus::class,
|
||||
];
|
||||
|
||||
const VALID_TRANSITIONS = [
|
||||
'pending' => ['confirmed', 'rejected', 'expired', 'member_cancelled'],
|
||||
'confirmed' => ['completed', 'member_cancelled', 'provider_cancelled'],
|
||||
'completed' => [],
|
||||
'rejected' => [],
|
||||
'expired' => [],
|
||||
'member_cancelled' => [],
|
||||
'provider_cancelled' => [],
|
||||
];
|
||||
|
||||
public function canTransitionTo(BookingStatus $newStatus): bool
|
||||
{
|
||||
$current = $this->status->value;
|
||||
$allowed = self::VALID_TRANSITIONS[$current] ?? [];
|
||||
return in_array($newStatus->value, $allowed);
|
||||
}
|
||||
|
||||
public function schedule()
|
||||
{
|
||||
return $this->belongsTo(CourseSchedule::class, 'schedule_id');
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'member_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CourseImage extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
const CREATED_AT = 'created_at';
|
||||
|
||||
protected $fillable = [
|
||||
'diving_offer_id',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function divingOffer()
|
||||
{
|
||||
return $this->belongsTo(DivingOffer::class);
|
||||
}
|
||||
|
||||
public function getUrlAttribute(): string
|
||||
{
|
||||
return Storage::disk('public')->url($this->image_path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ScheduleStatus;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CourseSchedule extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'diving_offer_id',
|
||||
'provider_id',
|
||||
'scheduled_date',
|
||||
'start_time',
|
||||
'max_participants',
|
||||
'current_participants',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_date' => 'date',
|
||||
'max_participants' => 'integer',
|
||||
'current_participants' => 'integer',
|
||||
'status' => ScheduleStatus::class,
|
||||
];
|
||||
|
||||
public function divingOffer()
|
||||
{
|
||||
return $this->belongsTo(DivingOffer::class);
|
||||
}
|
||||
|
||||
public function provider()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'provider_id');
|
||||
}
|
||||
|
||||
public function bookings()
|
||||
{
|
||||
return $this->hasMany(Booking::class, 'schedule_id');
|
||||
}
|
||||
|
||||
public function remainingSpots(): int
|
||||
{
|
||||
return max(0, $this->max_participants - $this->current_participants);
|
||||
}
|
||||
|
||||
protected function startTime(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn($value) => $value ? substr($value, 0, 5) : $value,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DivingOffer extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'diving_offers';
|
||||
|
||||
protected $fillable = [
|
||||
'provider_id',
|
||||
'title',
|
||||
'location',
|
||||
'spot',
|
||||
'rating',
|
||||
'reviews',
|
||||
'price',
|
||||
'badges',
|
||||
'description',
|
||||
'tag',
|
||||
'region',
|
||||
'cover_image',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'badges' => 'array',
|
||||
'rating' => 'float',
|
||||
'price' => 'integer',
|
||||
'reviews'=> 'integer',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function ($offer) {
|
||||
Storage::disk('public')->deleteDirectory("offers/{$offer->id}");
|
||||
});
|
||||
}
|
||||
|
||||
public function getCoverImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->cover_image
|
||||
? Storage::disk('public')->url($this->cover_image)
|
||||
: null;
|
||||
}
|
||||
|
||||
public function provider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'provider_id');
|
||||
}
|
||||
|
||||
public function schedules()
|
||||
{
|
||||
return $this->hasMany(CourseSchedule::class, 'diving_offer_id');
|
||||
}
|
||||
|
||||
public function courseImages()
|
||||
{
|
||||
return $this->hasMany(CourseImage::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function reviews()
|
||||
{
|
||||
return $this->hasMany(Review::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Review extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'diving_offer_id',
|
||||
'member_id',
|
||||
'rating',
|
||||
'comment',
|
||||
'helpful_count',
|
||||
'is_edited',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'rating' => 'integer',
|
||||
'helpful_count' => 'integer',
|
||||
'is_edited' => 'boolean',
|
||||
];
|
||||
|
||||
public function divingOffer()
|
||||
{
|
||||
return $this->belongsTo(DivingOffer::class);
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'member_id');
|
||||
}
|
||||
|
||||
public function edit()
|
||||
{
|
||||
return $this->hasOne(ReviewEdit::class);
|
||||
}
|
||||
|
||||
public function votes()
|
||||
{
|
||||
return $this->hasMany(ReviewVote::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReviewEdit extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'review_id',
|
||||
'old_rating',
|
||||
'old_comment',
|
||||
'edited_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'old_rating' => 'integer',
|
||||
'edited_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function review()
|
||||
{
|
||||
return $this->belongsTo(Review::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReviewVote extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'review_id',
|
||||
'member_id',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
public function review()
|
||||
{
|
||||
return $this->belongsTo(Review::class);
|
||||
}
|
||||
|
||||
public function member()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'member_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingCancelledNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(
|
||||
public readonly Booking $booking,
|
||||
public readonly string $cancelledBy,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
if ($this->cancelledBy === 'member') {
|
||||
return [
|
||||
'type' => 'booking_cancelled',
|
||||
'title' => '學員取消了預約',
|
||||
'body' => "學員已取消《{$offer->title}》的預約(時段:{$date})",
|
||||
'action_url' => config('app.frontend_url') . '/coach/bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'booking_cancelled',
|
||||
'title' => '教練取消了你的預約',
|
||||
'body' => "教練已取消你的《{$offer->title}》預約(時段:{$date}),如有疑問請聯繫教練",
|
||||
'action_url' => config('app.frontend_url') . '/my-bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
if ($this->cancelledBy === 'member') {
|
||||
return (new MailMessage)
|
||||
->subject('預約已取消 — CFDivePlatform')
|
||||
->greeting('通知')
|
||||
->line("學員已取消《{$offer->title}》的預約(時段:{$date})。")
|
||||
->action('查看所有預約', config('app.frontend_url') . '/coach/bookings')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('你的預約已取消 — CFDivePlatform')
|
||||
->greeting('通知')
|
||||
->line("教練已取消你的《{$offer->title}》預約(時段:{$date})。如有疑問,請聯繫課程教練。")
|
||||
->action('查看預約', config('app.frontend_url') . '/my-bookings')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingCompletedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Booking $booking) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
|
||||
return [
|
||||
'type' => 'booking_completed',
|
||||
'title' => '課程已完成,歡迎留下評價',
|
||||
'body' => "你的《{$offer->title}》課程已完成,歡迎分享你的學習心得!",
|
||||
'action_url' => config('app.frontend_url') . '/courses/' . $offer->id,
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$url = config('app.frontend_url') . '/courses/' . $offer->id;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('課程完成,歡迎留下評價 — CFDivePlatform')
|
||||
->greeting('恭喜完成課程!')
|
||||
->line("你的《{$offer->title}》課程已完成。")
|
||||
->action('前往評價', $url)
|
||||
->line('你的評價對其他學員非常有幫助,感謝你的分享!')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingConfirmedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Booking $booking) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
return [
|
||||
'type' => 'booking_confirmed',
|
||||
'title' => '預約已確認',
|
||||
'body' => "你的《{$offer->title}》課程預約已由教練確認(時段:{$date})",
|
||||
'action_url' => config('app.frontend_url') . '/my-bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
$url = config('app.frontend_url') . '/my-bookings';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('你的預約已確認 — CFDivePlatform')
|
||||
->greeting('好消息!')
|
||||
->line("你的《{$offer->title}》課程預約已由教練確認(時段:{$date})。")
|
||||
->action('查看預約', $url)
|
||||
->line('請準時出席,如需取消請至少提前 24 小時操作。')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingCreatedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Booking $booking) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
return [
|
||||
'type' => 'booking_created',
|
||||
'title' => '你有新的預約申請',
|
||||
'body' => "學員申請了《{$offer->title}》的預約(時段:{$date})",
|
||||
'action_url' => config('app.frontend_url') . '/coach/bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
$url = config('app.frontend_url') . '/coach/bookings';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('你有新的預約申請 — CFDivePlatform')
|
||||
->greeting('你好!')
|
||||
->line("學員申請了《{$offer->title}》的預約(時段:{$date})。")
|
||||
->action('查看預約', $url)
|
||||
->line('請盡快確認或拒絕此預約申請。')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Booking;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BookingRejectedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Booking $booking) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
|
||||
return [
|
||||
'type' => 'booking_rejected',
|
||||
'title' => '預約申請未通過',
|
||||
'body' => "你的《{$offer->title}》預約申請(時段:{$date})未通過,請選擇其他時段",
|
||||
'action_url' => config('app.frontend_url') . '/my-bookings',
|
||||
'related_id' => $this->booking->id,
|
||||
'related_type' => 'booking',
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$offer = $this->booking->schedule->divingOffer;
|
||||
$date = $this->booking->schedule->scheduled_date->toDateString();
|
||||
$url = config('app.frontend_url') . '/courses';
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('你的預約申請未通過 — CFDivePlatform')
|
||||
->greeting('通知')
|
||||
->line("很抱歉,你的《{$offer->title}》預約申請(時段:{$date})未通過審核。")
|
||||
->action('瀏覽其他課程', $url)
|
||||
->line('如有疑問,請聯繫課程教練。')
|
||||
->salutation('CFDivePlatform 團隊');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Review;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ReviewReceivedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly Review $review) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$offer = $this->review->divingOffer;
|
||||
|
||||
return [
|
||||
'type' => 'review_received',
|
||||
'title' => '你收到了一則新評價',
|
||||
'body' => "《{$offer->title}》收到了 {$this->review->rating} 星評價",
|
||||
'action_url' => config('app.frontend_url') . '/coach/reviews',
|
||||
'related_id' => $this->review->id,
|
||||
'related_type' => 'review',
|
||||
];
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -12,7 +12,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
//
|
||||
$middleware->alias([
|
||||
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
|
||||
@@ -15,6 +15,8 @@ return [
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
'frontend_url' => env('FRONTEND_URL', 'http://localhost:5173'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your settings for cross-origin resource sharing
|
||||
| or "CORS". This determines what cross-origin operations may execute
|
||||
| in web browsers. You are free to adjust these settings as needed.
|
||||
|
|
||||
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['api/*', 'sanctum/csrf-cookie'],
|
||||
|
||||
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
|
||||
'allowed_origins' => [
|
||||
env('FRONTEND_URL', 'http://localhost:5173'),
|
||||
'http://127.0.0.1:5173',
|
||||
'http://localhost:5173',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => false,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('provider_id')->nullable()->after('id');
|
||||
$table->foreign('provider_id')->references('id')->on('users')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->dropForeign(['provider_id']);
|
||||
$table->dropColumn('provider_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->string('spot')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->string('spot')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('course_schedules', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
|
||||
$table->foreignId('provider_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->date('scheduled_date');
|
||||
$table->time('start_time');
|
||||
$table->unsignedInteger('max_participants');
|
||||
$table->unsignedInteger('current_participants')->default(0);
|
||||
$table->string('status')->default('open');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['diving_offer_id', 'status', 'scheduled_date'], 'idx_offer_status_date');
|
||||
$table->index('provider_id', 'idx_provider_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('course_schedules');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('bookings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('schedule_id')->constrained('course_schedules')->cascadeOnDelete();
|
||||
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->unsignedInteger('participants')->default(1);
|
||||
$table->unsignedInteger('total_price');
|
||||
$table->string('status')->default('pending');
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['member_id', 'status'], 'idx_member_status');
|
||||
$table->index(['schedule_id', 'status'], 'idx_schedule_status');
|
||||
$table->index(['status', 'created_at'], 'idx_status_created_at');
|
||||
$table->index(['status', 'schedule_id'], 'idx_status_sched');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bookings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('reviews', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
|
||||
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->tinyInteger('rating')->unsigned();
|
||||
$table->text('comment');
|
||||
$table->unsignedInteger('helpful_count')->default(0);
|
||||
$table->boolean('is_edited')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['member_id', 'diving_offer_id']);
|
||||
$table->index(['diving_offer_id', 'helpful_count'], 'idx_reviews_helpful');
|
||||
$table->index(['diving_offer_id', 'rating'], 'idx_reviews_rating');
|
||||
$table->index(['diving_offer_id', 'created_at'], 'idx_reviews_newest');
|
||||
$table->index(['member_id', 'diving_offer_id'], 'idx_reviews_member_offer');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('reviews');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('review_edits', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('review_id')->unique()->constrained('reviews')->cascadeOnDelete();
|
||||
$table->tinyInteger('old_rating')->unsigned();
|
||||
$table->text('old_comment');
|
||||
$table->timestamp('edited_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('review_edits');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('review_votes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('review_id')->constrained('reviews')->cascadeOnDelete();
|
||||
$table->foreignId('member_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->unique(['review_id', 'member_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('review_votes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->string('cover_image', 500)->nullable()->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('diving_offers', function (Blueprint $table) {
|
||||
$table->dropColumn('cover_image');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('course_images', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('diving_offer_id')->constrained('diving_offers')->cascadeOnDelete();
|
||||
$table->string('image_path', 500);
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['diving_offer_id', 'sort_order'], 'idx_course_images_offer_sort');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('course_images');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Enums\BookingStatus;
|
||||
use App\Enums\ScheduleStatus;
|
||||
use App\Models\AdminProfile;
|
||||
use App\Models\Booking;
|
||||
use App\Models\CourseSchedule;
|
||||
use App\Models\DivingOffer;
|
||||
use App\Models\MemberProfile;
|
||||
use App\Models\ProviderProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DevelopmentSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Admin
|
||||
$admin = User::firstOrCreate(['email' => 'admin@cfdive.com'], [
|
||||
'name' => '平台管理員', 'password' => Hash::make('password123'),
|
||||
'role' => 'admin', 'is_active' => true,
|
||||
]);
|
||||
AdminProfile::firstOrCreate(['user_id' => $admin->id], ['department' => '營運']);
|
||||
|
||||
// Coach
|
||||
$coach = User::firstOrCreate(['email' => 'coach@cfdive.com'], [
|
||||
'name' => '蔡教練', 'password' => Hash::make('password123'),
|
||||
'role' => 'provider', 'is_active' => true,
|
||||
]);
|
||||
ProviderProfile::firstOrCreate(['user_id' => $coach->id], [
|
||||
'business_name' => '藍海潛水工作室',
|
||||
'description' => '專業 PADI 認證教練,10 年教學經驗',
|
||||
'contact_phone' => '0912345678',
|
||||
'contact_email' => 'coach@cfdive.com',
|
||||
'is_verified' => true,
|
||||
]);
|
||||
|
||||
// Member
|
||||
$member = User::firstOrCreate(['email' => 'member@cfdive.com'], [
|
||||
'name' => '測試會員', 'password' => Hash::make('password123'),
|
||||
'role' => 'member', 'is_active' => true,
|
||||
]);
|
||||
MemberProfile::firstOrCreate(['user_id' => $member->id], ['gender' => 'male']);
|
||||
|
||||
// Offers
|
||||
$offer = DivingOffer::firstOrCreate(
|
||||
['title' => '潛入海底 — 入門體驗', 'provider_id' => $coach->id],
|
||||
[
|
||||
'location' => '墾丁', 'spot' => '南灣', 'price' => 6000,
|
||||
'region' => '南部', 'tag' => '初學者',
|
||||
'badges' => ['PADI認證', '含裝備'],
|
||||
'description' => '適合零基礎的水肺潛水入門課程,由專業教練全程陪同。',
|
||||
'rating' => 0, 'reviews' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
DivingOffer::firstOrCreate(
|
||||
['title' => '進階深潛探索', 'provider_id' => $coach->id],
|
||||
[
|
||||
'location' => '小琉球', 'spot' => '美人洞', 'price' => 9800,
|
||||
'region' => '南部', 'tag' => '進階',
|
||||
'badges' => ['AOW認證', '含住宿'],
|
||||
'description' => '探索 30 米深海,適合已有 OW 認證的潛水愛好者。',
|
||||
'rating' => 0, 'reviews' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
// 未來時段(開放預約)
|
||||
$futureSchedule = CourseSchedule::firstOrCreate(
|
||||
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->addDays(14)->toDateString()],
|
||||
[
|
||||
'provider_id' => $coach->id, 'start_time' => '09:00',
|
||||
'max_participants' => 5, 'current_participants' => 0,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]
|
||||
);
|
||||
|
||||
// 過去時段(供測試 completed booking)
|
||||
$pastSchedule = CourseSchedule::firstOrCreate(
|
||||
['diving_offer_id' => $offer->id, 'scheduled_date' => now()->subDays(7)->toDateString()],
|
||||
[
|
||||
'provider_id' => $coach->id, 'start_time' => '09:00',
|
||||
'max_participants' => 5, 'current_participants' => 1,
|
||||
'status' => ScheduleStatus::Open,
|
||||
]
|
||||
);
|
||||
|
||||
// Pending booking(未來)
|
||||
Booking::firstOrCreate(
|
||||
['schedule_id' => $futureSchedule->id, 'member_id' => $member->id],
|
||||
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Pending]
|
||||
);
|
||||
|
||||
// Completed booking(可評價)
|
||||
Booking::firstOrCreate(
|
||||
['schedule_id' => $pastSchedule->id, 'member_id' => $member->id],
|
||||
['participants' => 1, 'total_price' => $offer->price, 'status' => BookingStatus::Completed]
|
||||
);
|
||||
|
||||
$this->command->info('✅ Seed 完成');
|
||||
$this->command->info(' Admin: admin@cfdive.com / password123');
|
||||
$this->command->info(' Coach: coach@cfdive.com / password123');
|
||||
$this->command->info(' Member: member@cfdive.com / password123');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
# Docker Compose 版本
|
||||
version: '3.8'
|
||||
|
||||
# 定義所有服務
|
||||
services:
|
||||
# PHP-FPM 服務
|
||||
app:
|
||||
# 構建配置
|
||||
build:
|
||||
context: . # 使用當前目錄作為構建上下文
|
||||
dockerfile: Dockerfile # 指定 Dockerfile 路徑
|
||||
image: cfdive-platform # 構建的鏡像名稱
|
||||
container_name: cfdive-app # 容器名稱
|
||||
restart: unless-stopped # 自動重啟策略:除非手動停止,否則自動重啟
|
||||
working_dir: /var/www/ # 工作目錄
|
||||
|
||||
# 卷掛載:將本地代碼掛載到容器中
|
||||
volumes:
|
||||
- ./:/var/www # 本地代碼目錄掛載到容器中的 /var/www
|
||||
|
||||
# 環境變數:設置 Laravel 數據庫連接
|
||||
environment:
|
||||
- DB_CONNECTION=mysql # 數據庫類型
|
||||
- DB_HOST=db # 數據庫主機名
|
||||
- DB_PORT=3306 # 數據庫端口
|
||||
- DB_DATABASE=CFDivePlatform # 數據庫名稱
|
||||
- DB_USERNAME=cfdiveuser # 數據庫用戶名
|
||||
- DB_PASSWORD=cfdivepass # 數據庫密碼
|
||||
|
||||
# 網絡配置
|
||||
networks:
|
||||
- cfdive-network # 連接到自定義網絡
|
||||
|
||||
# 依賴關係:確保 db 和 redis 服務先啟動
|
||||
depends_on:
|
||||
db: # 依賴 db 服務
|
||||
condition: service_healthy # 等待數據庫健康檢查通過
|
||||
redis: # 依賴 redis 服務
|
||||
condition: service_started # 等待服務啟動
|
||||
|
||||
# 健康檢查配置
|
||||
healthcheck:
|
||||
test: ["CMD", "php", "-v"] # 檢查 PHP 版本確認服務正常
|
||||
interval: 30s # 每30秒檢查一次
|
||||
timeout: 10s # 超時時間10秒
|
||||
retries: 3 # 重試3次
|
||||
start_period: 40s # 容器啟動後40秒開始檢查
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: cfdive-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8080:80
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
|
||||
networks:
|
||||
- cfdive-network
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: http://localhost:8080
|
||||
image: cfdive-frontend
|
||||
container_name: cfdive-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5173:80"
|
||||
networks:
|
||||
- cfdive-network
|
||||
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: cfdive-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: CFDivePlatform
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_USER: cfdiveuser
|
||||
MYSQL_PASSWORD: cfdivepass
|
||||
SERVICE_TAGS: dev
|
||||
SERVICE_NAME: mysql
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
networks:
|
||||
- cfdive-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "cfdiveuser", "-pcfdivepass"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
phpmyadmin:
|
||||
image: phpmyadmin/phpmyadmin
|
||||
container_name: cfdive-phpmyadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:80"
|
||||
environment:
|
||||
PMA_HOST: db
|
||||
PMA_PORT: 3306
|
||||
PMA_USER: cfdiveuser
|
||||
PMA_PASSWORD: cfdivepass
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
networks:
|
||||
- cfdive-network
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
queue-worker:
|
||||
image: cfdive-platform
|
||||
container_name: cfdive-queue
|
||||
restart: unless-stopped
|
||||
working_dir: /var/www/
|
||||
command: php artisan queue:work --sleep=3 --tries=3 --timeout=60
|
||||
volumes:
|
||||
- ./:/var/www
|
||||
environment:
|
||||
- DB_CONNECTION=mysql
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_DATABASE=CFDivePlatform
|
||||
- DB_USERNAME=cfdiveuser
|
||||
- DB_PASSWORD=cfdivepass
|
||||
networks:
|
||||
- cfdive-network
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit
|
||||
container_name: cfdive-mailpit
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "1025:1025"
|
||||
networks:
|
||||
- cfdive-network
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: cfdive-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- cfdive-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
cfdive-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,58 @@
|
||||
# 定義一個 HTTP 服務器塊
|
||||
server {
|
||||
listen 80;
|
||||
server_name cfdive.local localhost;
|
||||
|
||||
# 默認索引文件,按順序嘗試
|
||||
index index.php index.html;
|
||||
|
||||
# 錯誤日誌和訪問日誌的路徑
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
# 網站根目錄,指向 Laravel 的 public 目錄
|
||||
root /var/www/public;
|
||||
|
||||
# 客戶端上傳文件大小限制
|
||||
client_max_body_size 100M;
|
||||
|
||||
# 處理所有請求
|
||||
location / {
|
||||
# 嘗試以 URI 作為文件查找,然後作為目錄,最後轉發到 index.php
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
|
||||
# 啟用靜態 gzip 文件服務(如果存在 .gz 文件)
|
||||
gzip_static on;
|
||||
}
|
||||
|
||||
# 處理 PHP 文件請求
|
||||
location ~ \.php$ {
|
||||
# 如果文件不存在,返回 404
|
||||
try_files $uri =404;
|
||||
|
||||
# 分割路徑信息,用於處理 PATH_INFO
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
|
||||
# 轉發 PHP 請求到 PHP-FPM 服務
|
||||
fastcgi_pass app:9000;
|
||||
|
||||
# 設置 FastCGI 緩衝區大小
|
||||
fastcgi_buffers 16 16k;
|
||||
fastcgi_buffer_size 32k;
|
||||
|
||||
# 默認索引文件
|
||||
fastcgi_index index.php;
|
||||
|
||||
# 設置 FastCGI 讀取超時時間(秒)
|
||||
fastcgi_read_timeout 600;
|
||||
|
||||
# 包含 FastCGI 參數
|
||||
include fastcgi_params;
|
||||
|
||||
# 設置 SCRIPT_FILENAME 參數,告訴 PHP-FPM 要執行的文件
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
|
||||
# 設置 PATH_INFO 參數,用於獲取路徑信息
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== CFDivePlatform 容器初始化開始 ==="
|
||||
|
||||
# 檢查目錄結構
|
||||
if [ ! -d "/var/www/storage" ]; then
|
||||
echo "創建 storage 目錄..."
|
||||
mkdir -p /var/www/storage
|
||||
fi
|
||||
|
||||
if [ ! -d "/var/www/bootstrap/cache" ]; then
|
||||
echo "創建 bootstrap/cache 目錄..."
|
||||
mkdir -p /var/www/bootstrap/cache
|
||||
fi
|
||||
|
||||
# 設置權限
|
||||
echo "設置目錄權限..."
|
||||
chown -R www-data:www-data /var/www
|
||||
chmod -R 775 /var/www/storage /var/www/bootstrap/cache
|
||||
|
||||
# 等待 MySQL 服務啟動
|
||||
echo "等待 MySQL 服務啟動..."
|
||||
|
||||
# 使用更穩定的方法檢查 MySQL 連接
|
||||
MAX_TRIES=60
|
||||
COUNT=0
|
||||
|
||||
wait_for_mysql() {
|
||||
while [ $COUNT -lt $MAX_TRIES ]; do
|
||||
if mysqladmin ping -h"db" -u"cfdiveuser" -p"cfdivepass" --silent 2>/dev/null; then
|
||||
echo "✅ MySQL 服務已準備就緒"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 備用檢查方法
|
||||
if php -r "
|
||||
try {
|
||||
\$pdo = new PDO('mysql:host=db;port=3306', 'cfdiveuser', 'cfdivepass');
|
||||
echo 'PHP-PDO-OK';
|
||||
exit(0);
|
||||
} catch(Exception \$e) {
|
||||
exit(1);
|
||||
}
|
||||
" 2>/dev/null; then
|
||||
echo "✅ MySQL 連接成功 (通過 PHP PDO)"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "⏳ 等待 MySQL... ($((COUNT+1))/$MAX_TRIES)"
|
||||
sleep 2
|
||||
COUNT=$((COUNT+1))
|
||||
done
|
||||
|
||||
if [ $COUNT -eq $MAX_TRIES ]; then
|
||||
echo "⚠️ 無法連接到 MySQL,但將繼續啟動服務"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_mysql
|
||||
|
||||
# 檢查並安裝 Composer 依賴
|
||||
echo "📦 檢查 Composer 依賴..."
|
||||
if [ -f "composer.json" ]; then
|
||||
if [ ! -d "vendor" ] || [ "composer.json" -nt "vendor/autoload.php" ]; then
|
||||
echo "安裝 Composer 依賴..."
|
||||
composer install --no-scripts --no-autoloader --optimize-autoloader
|
||||
composer dump-autoload --optimize
|
||||
else
|
||||
echo "✅ Composer 依賴已是最新"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 設置 Laravel 環境
|
||||
if [ ! -f .env ]; then
|
||||
echo "🔧 創建 .env 檔案..."
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
else
|
||||
echo "✅ .env 檔案已存在"
|
||||
fi
|
||||
|
||||
# 更新環境變數以確保正確配置
|
||||
echo "🔧 更新資料庫配置..."
|
||||
sed -i "s/DB_HOST=.*/DB_HOST=db/g" .env
|
||||
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=cfdivepass/g" .env
|
||||
sed -i "s/DB_USERNAME=.*/DB_USERNAME=cfdiveuser/g" .env
|
||||
sed -i "s/DB_DATABASE=.*/DB_DATABASE=CFDivePlatform/g" .env
|
||||
|
||||
# 執行遷移(如果數據庫已準備好)
|
||||
echo "🗄️ 執行數據庫遷移..."
|
||||
if php artisan migrate:status 2>/dev/null; then
|
||||
php artisan migrate --force || echo "⚠️ 遷移執行遇到問題,但繼續執行"
|
||||
else
|
||||
echo "⚠️ 無法檢查遷移狀態,跳過遷移"
|
||||
fi
|
||||
|
||||
# 清除與優化 Laravel 緩存
|
||||
echo "🧹 清除 Laravel 緩存..."
|
||||
php artisan config:clear || true
|
||||
php artisan cache:clear || true
|
||||
php artisan route:clear || true
|
||||
php artisan view:clear || true
|
||||
|
||||
# 生成 Swagger 文檔(如果可能)
|
||||
if php -r "echo class_exists('L5Swagger\\L5SwaggerServiceProvider') ? 'yes' : 'no';" 2>/dev/null | grep -q 'yes'; then
|
||||
echo "📖 生成 API 文檔..."
|
||||
php artisan l5-swagger:generate || echo "⚠️ API 文檔生成失敗"
|
||||
fi
|
||||
|
||||
echo "✅ CFDivePlatform 初始化完成!"
|
||||
|
||||
# 建立 storage symlink
|
||||
echo "🔗 建立 storage symlink..."
|
||||
php artisan storage:link --force || true
|
||||
|
||||
# 啟動 cron daemon(Laravel Scheduler)
|
||||
echo "⏰ 啟動 Laravel Scheduler cron..."
|
||||
service cron start || cron || true
|
||||
|
||||
# 執行傳入的命令
|
||||
exec "$@"
|
||||
@@ -0,0 +1,5 @@
|
||||
upload_max_filesize=40M
|
||||
post_max_size=40M
|
||||
memory_limit=512M
|
||||
max_execution_time=600
|
||||
max_input_vars=10000
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL=http://localhost:8080
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>cf-dive-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
}
|
||||
Generated
+3855
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "cf-dive-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
import NotificationDrawer from './components/NotificationDrawer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isBackofficePage = computed(() =>
|
||||
route.path.startsWith('/coach') || route.path.startsWith('/admin')
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<NavBar v-if="!isBackofficePage" />
|
||||
<RouterView />
|
||||
<NotificationDrawer />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const adminApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
adminApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default adminApi
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,17 @@
|
||||
import api from './axios'
|
||||
|
||||
export function getMyBookings() {
|
||||
return api.get('/member/bookings')
|
||||
}
|
||||
|
||||
export function getBooking(id) {
|
||||
return api.get(`/member/bookings/${id}`)
|
||||
}
|
||||
|
||||
export function createBooking(payload) {
|
||||
return api.post('/member/bookings', payload)
|
||||
}
|
||||
|
||||
export function cancelBooking(id) {
|
||||
return api.delete(`/member/bookings/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const coachApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
coachApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('coach_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default coachApi
|
||||
@@ -0,0 +1,21 @@
|
||||
import coachApi from './coachAxios'
|
||||
|
||||
export function getProviderBookings() {
|
||||
return coachApi.get('/provider/bookings')
|
||||
}
|
||||
|
||||
export function confirmBooking(id) {
|
||||
return coachApi.put(`/provider/bookings/${id}/confirm`)
|
||||
}
|
||||
|
||||
export function rejectBooking(id) {
|
||||
return coachApi.put(`/provider/bookings/${id}/reject`)
|
||||
}
|
||||
|
||||
export function cancelBooking(id) {
|
||||
return coachApi.put(`/provider/bookings/${id}/cancel`)
|
||||
}
|
||||
|
||||
export function completeBooking(id) {
|
||||
return coachApi.put(`/provider/bookings/${id}/complete`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import coachApi from './coachAxios'
|
||||
|
||||
export function getSchedules() {
|
||||
return coachApi.get('/provider/schedules')
|
||||
}
|
||||
|
||||
export function createSchedule(payload) {
|
||||
return coachApi.post('/provider/schedules', payload)
|
||||
}
|
||||
|
||||
export function updateSchedule(id, payload) {
|
||||
return coachApi.put(`/provider/schedules/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteSchedule(id) {
|
||||
return coachApi.delete(`/provider/schedules/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import coachApi from './coachAxios'
|
||||
|
||||
function toFormData(file) {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return fd
|
||||
}
|
||||
|
||||
export function uploadCover(offerId, file) {
|
||||
return coachApi.post(`/provider/offers/${offerId}/cover`, toFormData(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCover(offerId) {
|
||||
return coachApi.delete(`/provider/offers/${offerId}/cover`)
|
||||
}
|
||||
|
||||
export function uploadImage(offerId, file) {
|
||||
return coachApi.post(`/provider/offers/${offerId}/images`, toFormData(file), {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteImage(imageId) {
|
||||
return coachApi.delete(`/provider/images/${imageId}`)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const notificationApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
notificationApi.interceptors.request.use((config) => {
|
||||
// 優先用 coach_token,因為 coach 身份通知優先;member 也可用自己的 token
|
||||
// 兩者都存在時(測試情境),以當前頁面路徑決定:/coach 開頭用 coach_token,其餘用 token
|
||||
const isCoachPage = window.location.pathname.startsWith('/coach')
|
||||
const token = isCoachPage
|
||||
? (localStorage.getItem('coach_token') || localStorage.getItem('token'))
|
||||
: (localStorage.getItem('token') || localStorage.getItem('coach_token'))
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default notificationApi
|
||||
@@ -0,0 +1,27 @@
|
||||
import api from './axios'
|
||||
import axios from 'axios'
|
||||
|
||||
const publicApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
export function getReviews(offerId, sort = 'helpful') {
|
||||
return publicApi.get(`/diving-offers/${offerId}/reviews`, { params: { sort } })
|
||||
}
|
||||
|
||||
export function createReview(payload) {
|
||||
return api.post('/member/reviews', payload)
|
||||
}
|
||||
|
||||
export function updateReview(id, payload) {
|
||||
return api.put(`/member/reviews/${id}`, payload)
|
||||
}
|
||||
|
||||
export function deleteReview(id) {
|
||||
return api.delete(`/member/reviews/${id}`)
|
||||
}
|
||||
|
||||
export function toggleHelpful(reviewId) {
|
||||
return api.post(`/reviews/${reviewId}/helpful`)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const publicApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL + '/api',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
export function getSchedulesByOffer(offerId) {
|
||||
return publicApi.get(`/diving-offers/${offerId}/schedules`)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { useAdminAuthStore } from '../stores/adminAuth'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const adminAuth = useAdminAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await adminAuth.logout()
|
||||
router.push('/admin/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-slate-800 text-white shadow-md">
|
||||
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="text-lg font-bold tracking-wide">⚙️ Admin Panel</span>
|
||||
<RouterLink to="/admin/dashboard" class="text-sm hover:text-slate-300 transition">儀表板</RouterLink>
|
||||
<RouterLink to="/admin/members" class="text-sm hover:text-slate-300 transition">會員管理</RouterLink>
|
||||
<RouterLink to="/admin/providers" class="text-sm hover:text-slate-300 transition">教練管理</RouterLink>
|
||||
<RouterLink to="/admin/offers" class="text-sm hover:text-slate-300 transition">課程管理</RouterLink>
|
||||
<RouterLink to="/admin/bookings" class="text-sm hover:text-slate-300 transition">預約管理</RouterLink>
|
||||
<RouterLink to="/admin/reviews" class="text-sm hover:text-slate-300 transition">評價管理</RouterLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="text-slate-400">{{ adminAuth.user?.name }}</span>
|
||||
<button @click="handleLogout"
|
||||
class="bg-slate-600 hover:bg-slate-500 px-4 py-1.5 rounded-full transition">
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import { useCoachAuthStore } from '../stores/coachAuth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NotificationBell from './NotificationBell.vue'
|
||||
|
||||
const coachAuth = useCoachAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await coachAuth.logout()
|
||||
router.push('/coach/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-gray-900 text-white shadow-md">
|
||||
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-6">
|
||||
<RouterLink to="/coach/dashboard" class="text-lg font-bold tracking-wide hover:text-gray-300 transition">
|
||||
🤿 Coach Portal
|
||||
</RouterLink>
|
||||
<RouterLink to="/coach/dashboard" class="text-sm hover:text-gray-300 transition">我的課程</RouterLink>
|
||||
<RouterLink to="/coach/schedules" class="text-sm hover:text-gray-300 transition">時段管理</RouterLink>
|
||||
<RouterLink to="/coach/bookings" class="text-sm hover:text-gray-300 transition">預約管理</RouterLink>
|
||||
<RouterLink to="/coach/reviews" class="text-sm hover:text-gray-300 transition">課程評價</RouterLink>
|
||||
<RouterLink to="/coach/profile" class="text-sm hover:text-gray-300 transition">個人資料</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="text-gray-400">{{ coachAuth.user?.name }}</span>
|
||||
<NotificationBell />
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="bg-gray-700 hover:bg-gray-600 px-4 py-1.5 rounded-full transition"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
offer: { type: Object, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/courses/${offer.id}`"
|
||||
class="bg-white rounded-2xl shadow hover:shadow-lg transition overflow-hidden flex flex-col"
|
||||
>
|
||||
<div class="h-40 overflow-hidden">
|
||||
<img
|
||||
v-if="offer.cover_image_url"
|
||||
:src="offer.cover_image_url"
|
||||
:alt="offer.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="bg-gradient-to-br from-ocean-700 to-ocean-500 h-full flex items-center justify-center text-white text-5xl">
|
||||
🤿
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex flex-col gap-2 flex-1">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<span
|
||||
v-for="badge in (offer.badges || [])"
|
||||
:key="badge"
|
||||
class="text-xs bg-ocean-100 text-ocean-700 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{{ badge }}
|
||||
</span>
|
||||
<span v-if="offer.tag" class="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{{ offer.tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-semibold text-gray-800 line-clamp-2 leading-snug">{{ offer.title }}</h3>
|
||||
|
||||
<p class="text-sm text-gray-500 flex items-center gap-1">
|
||||
📍 {{ offer.location }}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<span class="text-sm text-amber-500 font-medium">
|
||||
★ {{ offer.rating }} <span class="text-gray-400">({{ offer.reviews }})</span>
|
||||
</span>
|
||||
<span class="text-ocean-700 font-bold">NT$ {{ offer.price.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NotificationBell from './NotificationBell.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-ocean-800 text-white shadow-md">
|
||||
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<RouterLink to="/" class="text-xl font-bold tracking-wide hover:text-ocean-100 transition">
|
||||
🤿 CFDive
|
||||
</RouterLink>
|
||||
|
||||
<div class="flex items-center gap-6 text-sm font-medium">
|
||||
<RouterLink to="/courses" class="hover:text-ocean-100 transition">探索課程</RouterLink>
|
||||
|
||||
<template v-if="auth.isLoggedIn">
|
||||
<span class="text-ocean-200 hidden sm:inline">
|
||||
👤 {{ auth.user?.name }}
|
||||
</span>
|
||||
<RouterLink to="/my-bookings" class="hover:text-ocean-100 transition">我的預約</RouterLink>
|
||||
<RouterLink to="/profile" class="hover:text-ocean-100 transition">個人資料</RouterLink>
|
||||
<NotificationBell />
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<RouterLink to="/login" class="hover:text-ocean-100 transition">登入</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="bg-ocean-600 hover:bg-ocean-500 px-4 py-1.5 rounded-full transition"
|
||||
>
|
||||
註冊
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
|
||||
const store = useNotificationStore()
|
||||
|
||||
function toggle() {
|
||||
if (!store.isOpen) {
|
||||
store.fetchNotifications()
|
||||
}
|
||||
store.isOpen = !store.isOpen
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="toggle"
|
||||
class="relative p-2 rounded-full hover:bg-white/10 transition"
|
||||
aria-label="通知"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 10-12 0v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0a3 3 0 11-6 0m6 0H9" />
|
||||
</svg>
|
||||
<span
|
||||
v-if="store.unreadCount > 0"
|
||||
class="absolute -top-0.5 -right-0.5 min-w-[1.1rem] h-[1.1rem] flex items-center justify-center
|
||||
bg-red-500 text-white text-[10px] font-bold rounded-full px-0.5 leading-none"
|
||||
>
|
||||
{{ store.unreadCount > 99 ? '99+' : store.unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const store = useNotificationStore()
|
||||
const router = useRouter()
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60000)
|
||||
if (m < 1) return '剛剛'
|
||||
if (m < 60) return `${m} 分鐘前`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h} 小時前`
|
||||
return `${Math.floor(h / 24)} 天前`
|
||||
}
|
||||
|
||||
function truncate(text, max = 80) {
|
||||
return text && text.length > max ? text.slice(0, max) + '…' : text
|
||||
}
|
||||
|
||||
async function clickItem(item) {
|
||||
await store.markRead(item.id)
|
||||
store.isOpen = false
|
||||
if (item.action_url) {
|
||||
try {
|
||||
const path = new URL(item.action_url).pathname
|
||||
await router.push(path)
|
||||
} catch (e) {
|
||||
console.error('[NotificationDrawer] navigation failed:', item.action_url, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="drawer">
|
||||
<div v-if="store.isOpen" class="fixed inset-0 z-50 flex justify-end">
|
||||
<div class="absolute inset-0 bg-black/30" @click="store.isOpen = false" />
|
||||
|
||||
<div class="relative w-80 sm:w-96 h-full bg-white shadow-2xl flex flex-col">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b">
|
||||
<h2 class="font-semibold text-gray-800">通知</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="store.unreadCount > 0"
|
||||
@click="store.markAllRead()"
|
||||
class="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
全部標為已讀
|
||||
</button>
|
||||
<button @click="store.isOpen = false" class="text-gray-400 hover:text-gray-600 p-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<p v-if="store.notifications.length === 0" class="text-center text-gray-400 text-sm py-12">
|
||||
目前沒有通知
|
||||
</p>
|
||||
|
||||
<ul v-else>
|
||||
<li
|
||||
v-for="item in store.notifications"
|
||||
:key="item.id"
|
||||
class="flex items-start gap-3 px-4 py-3 border-b hover:bg-gray-50 transition cursor-pointer"
|
||||
:class="{ 'bg-blue-50': !item.read_at }"
|
||||
@click="clickItem(item)"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800 truncate">{{ item.title }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5 line-clamp-2">{{ truncate(item.body) }}</p>
|
||||
<p class="text-[10px] text-gray-400 mt-1">{{ formatTime(item.created_at) }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="store.remove(item.id)"
|
||||
class="shrink-0 text-gray-300 hover:text-gray-500 p-1 mt-0.5"
|
||||
title="刪除"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.drawer-enter-active > div:last-child,
|
||||
.drawer-leave-active > div:last-child {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.drawer-enter-from > div:last-child,
|
||||
.drawer-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup>
|
||||
import AdminNavBar from '../components/AdminNavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-50">
|
||||
<AdminNavBar />
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup>
|
||||
import CoachNavBar from '../components/CoachNavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<CoachNavBar />
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useCoachAuthStore } from './stores/coachAuth'
|
||||
import { useAdminAuthStore } from './stores/adminAuth'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
|
||||
// 在 router 安裝前同步初始化所有 auth store,
|
||||
// 確保 beforeEach guard 跑時 isLoggedIn 已反映 localStorage 的實際狀態
|
||||
useAuthStore().init()
|
||||
useCoachAuthStore().init()
|
||||
useAdminAuthStore().init()
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useCoachAuthStore } from '../stores/coachAuth'
|
||||
import { useAdminAuthStore } from '../stores/adminAuth'
|
||||
|
||||
const routes = [
|
||||
// Member
|
||||
{ path: '/', component: () => import('../views/HomeView.vue') },
|
||||
{ path: '/courses', component: () => import('../views/CoursesView.vue') },
|
||||
{ path: '/courses/:id', component: () => import('../views/CourseDetailView.vue') },
|
||||
{ path: '/login', component: () => import('../views/LoginView.vue') },
|
||||
{ path: '/register', component: () => import('../views/RegisterView.vue') },
|
||||
{ path: '/auth/callback', component: () => import('../views/AuthCallbackView.vue') },
|
||||
{ path: '/profile', component: () => import('../views/ProfileView.vue'), meta: { requiresAuth: true } },
|
||||
{ path: '/my-bookings', component: () => import('../views/MyBookingsView.vue'), meta: { requiresAuth: true } },
|
||||
|
||||
// Coach (public)
|
||||
{ path: '/coach/login', component: () => import('../views/coach/LoginView.vue') },
|
||||
{ path: '/coach/register', component: () => import('../views/coach/RegisterView.vue') },
|
||||
// Coach (protected)
|
||||
{
|
||||
path: '/coach',
|
||||
component: () => import('../layouts/CoachLayout.vue'),
|
||||
meta: { requiresCoach: true },
|
||||
children: [
|
||||
{ path: 'dashboard', component: () => import('../views/coach/DashboardView.vue') },
|
||||
{ path: 'offers/new', component: () => import('../views/coach/OfferFormView.vue') },
|
||||
{ path: 'offers/:id/edit', component: () => import('../views/coach/OfferFormView.vue') },
|
||||
{ path: 'profile', component: () => import('../views/coach/ProfileView.vue') },
|
||||
{ path: 'schedules', component: () => import('../views/coach/ScheduleManagerView.vue') },
|
||||
{ path: 'bookings', component: () => import('../views/coach/BookingManagerView.vue') },
|
||||
{ path: 'reviews', component: () => import('../views/coach/ReviewsView.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
// Admin (public)
|
||||
{ path: '/admin/login', component: () => import('../views/admin/LoginView.vue') },
|
||||
// Admin (protected)
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('../layouts/AdminLayout.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
children: [
|
||||
{ path: 'dashboard', component: () => import('../views/admin/DashboardView.vue') },
|
||||
{ path: 'members', component: () => import('../views/admin/MembersView.vue') },
|
||||
{ path: 'providers', component: () => import('../views/admin/ProvidersView.vue') },
|
||||
{ path: 'offers', component: () => import('../views/admin/OffersView.vue') },
|
||||
{ path: 'bookings', component: () => import('../views/admin/BookingsView.vue') },
|
||||
{ path: 'reviews', component: () => import('../views/admin/ReviewsView.vue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore()
|
||||
const coachAuth = useCoachAuthStore()
|
||||
const adminAuth = useAdminAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isLoggedIn) return { path: '/login' }
|
||||
if (to.meta.requiresCoach && !coachAuth.isLoggedIn) return { path: '/coach/login' }
|
||||
if (to.meta.requiresAdmin && !adminAuth.isLoggedIn) return { path: '/admin/login' }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import adminApi from '../api/adminAxios'
|
||||
|
||||
export const useAdminAuthStore = defineStore('adminAuth', () => {
|
||||
const user = ref(null)
|
||||
const token = ref(null)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
function init() {
|
||||
const savedToken = localStorage.getItem('admin_token')
|
||||
const savedUser = localStorage.getItem('admin_user')
|
||||
if (savedToken) {
|
||||
token.value = savedToken
|
||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||
}
|
||||
}
|
||||
|
||||
function setAuth(userData, tokenValue) {
|
||||
user.value = userData
|
||||
token.value = tokenValue
|
||||
localStorage.setItem('admin_token', tokenValue)
|
||||
localStorage.setItem('admin_user', JSON.stringify(userData))
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await adminApi.post('/admin/logout')
|
||||
} catch {}
|
||||
user.value = null
|
||||
token.value = null
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
}
|
||||
|
||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api/axios'
|
||||
import { useNotificationStore } from './notifications'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const token = ref(null)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
function init() {
|
||||
const saved = localStorage.getItem('token')
|
||||
const savedUser = localStorage.getItem('user')
|
||||
if (saved) {
|
||||
token.value = saved
|
||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||
useNotificationStore().startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function setAuth(userData, tokenValue) {
|
||||
user.value = userData
|
||||
token.value = tokenValue
|
||||
localStorage.setItem('token', tokenValue)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
useNotificationStore().startPolling()
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.post('/member/logout')
|
||||
} catch {}
|
||||
useNotificationStore().stopPolling()
|
||||
user.value = null
|
||||
token.value = null
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import coachApi from '../api/coachAxios'
|
||||
import { useNotificationStore } from './notifications'
|
||||
|
||||
export const useCoachAuthStore = defineStore('coachAuth', () => {
|
||||
const user = ref(null)
|
||||
const token = ref(null)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
function init() {
|
||||
const savedToken = localStorage.getItem('coach_token')
|
||||
const savedUser = localStorage.getItem('coach_user')
|
||||
if (savedToken) {
|
||||
token.value = savedToken
|
||||
user.value = savedUser ? JSON.parse(savedUser) : null
|
||||
useNotificationStore().startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function setAuth(userData, tokenValue) {
|
||||
user.value = userData
|
||||
token.value = tokenValue
|
||||
localStorage.setItem('coach_token', tokenValue)
|
||||
localStorage.setItem('coach_user', JSON.stringify(userData))
|
||||
useNotificationStore().startPolling()
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await coachApi.post('/provider/logout')
|
||||
} catch {}
|
||||
useNotificationStore().stopPolling()
|
||||
user.value = null
|
||||
token.value = null
|
||||
localStorage.removeItem('coach_token')
|
||||
localStorage.removeItem('coach_user')
|
||||
}
|
||||
|
||||
return { user, token, isLoggedIn, init, setAuth, logout }
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/notificationAxios'
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', () => {
|
||||
const unreadCount = ref(0)
|
||||
const notifications = ref([])
|
||||
const isOpen = ref(false)
|
||||
|
||||
let intervalId = null
|
||||
let currentInterval = null
|
||||
let visibilityHandler = null
|
||||
|
||||
async function fetchUnreadCount() {
|
||||
try {
|
||||
const res = await api.get('/notifications/unread-count')
|
||||
const newCount = res.data?.data?.count ?? 0
|
||||
if (newCount !== unreadCount.value) {
|
||||
const wasZero = unreadCount.value === 0
|
||||
unreadCount.value = newCount
|
||||
if ((wasZero && newCount > 0) || (!wasZero && newCount === 0)) {
|
||||
restartInterval()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[NotificationStore] fetchUnreadCount failed:', e?.response?.status, e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
try {
|
||||
const res = await api.get('/notifications')
|
||||
notifications.value = res.data.data
|
||||
unreadCount.value = res.data.unread_count
|
||||
} catch (e) {
|
||||
console.error('[NotificationStore] fetchNotifications failed:', e?.response?.status, e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
function getInterval() {
|
||||
return unreadCount.value > 0 ? 30000 : 60000
|
||||
}
|
||||
|
||||
function restartInterval() {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
const ms = getInterval()
|
||||
currentInterval = ms
|
||||
intervalId = setInterval(fetchUnreadCount, ms)
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
fetchUnreadCount()
|
||||
restartInterval()
|
||||
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
intervalId = null
|
||||
} else {
|
||||
fetchUnreadCount()
|
||||
restartInterval()
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
intervalId = null
|
||||
if (visibilityHandler) {
|
||||
document.removeEventListener('visibilitychange', visibilityHandler)
|
||||
visibilityHandler = null
|
||||
}
|
||||
unreadCount.value = 0
|
||||
notifications.value = []
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function markRead(id) {
|
||||
const n = notifications.value.find(n => n.id === id)
|
||||
if (n && !n.read_at) {
|
||||
n.read_at = new Date().toISOString()
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
}
|
||||
try {
|
||||
await api.patch(`/notifications/${id}/read`)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
notifications.value.forEach(n => {
|
||||
if (!n.read_at) n.read_at = new Date().toISOString()
|
||||
})
|
||||
unreadCount.value = 0
|
||||
try {
|
||||
await api.patch('/notifications/read-all')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const n = notifications.value.find(n => n.id === id)
|
||||
if (n && !n.read_at) unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
notifications.value = notifications.value.filter(n => n.id !== id)
|
||||
try {
|
||||
await api.delete(`/notifications/${id}`)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
unreadCount, notifications, isOpen,
|
||||
fetchNotifications, fetchUnreadCount,
|
||||
startPolling, stopPolling,
|
||||
markRead, markAllRead, remove,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../api/axios'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
const token = route.query.token
|
||||
const error = route.query.error
|
||||
|
||||
if (error || !token) {
|
||||
router.push('/login?error=oauth_failed')
|
||||
return
|
||||
}
|
||||
|
||||
// 存 token 先,再拉 profile
|
||||
localStorage.setItem('token', token)
|
||||
try {
|
||||
const res = await api.get('/member/profile')
|
||||
auth.setAuth(res.data.data, token)
|
||||
} catch {
|
||||
auth.setAuth(null, token)
|
||||
}
|
||||
|
||||
// 清除 URL 上的 token
|
||||
history.replaceState({}, '', '/auth/callback')
|
||||
router.push('/courses')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="min-h-[80vh] flex items-center justify-center text-gray-400">
|
||||
正在完成登入,請稍候...
|
||||
</main>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user