diff --git a/app/Http/Controllers/API/AuthController.php b/app/Http/Controllers/API/AuthController.php index 6ac6f48..e1f8180 100644 --- a/app/Http/Controllers/API/AuthController.php +++ b/app/Http/Controllers/API/AuthController.php @@ -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(); // 加載服務提供者資料 diff --git a/app/Http/Controllers/API/ProviderOfferController.php b/app/Http/Controllers/API/ProviderOfferController.php new file mode 100644 index 0000000..b086349 --- /dev/null +++ b/app/Http/Controllers/API/ProviderOfferController.php @@ -0,0 +1,111 @@ +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' => '課程已刪除']); + } +} diff --git a/app/Models/DivingOffer.php b/app/Models/DivingOffer.php index 95a102e..d5fbe6d 100644 --- a/app/Models/DivingOffer.php +++ b/app/Models/DivingOffer.php @@ -11,6 +11,7 @@ class DivingOffer extends Model protected $table = 'diving_offers'; protected $fillable = [ + 'provider_id', 'title', 'location', 'spot', diff --git a/database/migrations/2026_05_09_184151_add_provider_id_to_diving_offers_table.php b/database/migrations/2026_05_09_184151_add_provider_id_to_diving_offers_table.php new file mode 100644 index 0000000..6f52c65 --- /dev/null +++ b/database/migrations/2026_05_09_184151_add_provider_id_to_diving_offers_table.php @@ -0,0 +1,27 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php b/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php new file mode 100644 index 0000000..b3783ac --- /dev/null +++ b/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php @@ -0,0 +1,22 @@ +string('spot')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('diving_offers', function (Blueprint $table) { + $table->string('spot')->nullable(false)->change(); + }); + } +}; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 118e9d0..6e7b2a3 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,15 +1,25 @@ diff --git a/frontend/src/api/coachAxios.js b/frontend/src/api/coachAxios.js new file mode 100644 index 0000000..b534f2d --- /dev/null +++ b/frontend/src/api/coachAxios.js @@ -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 diff --git a/frontend/src/components/CoachNavBar.vue b/frontend/src/components/CoachNavBar.vue new file mode 100644 index 0000000..6dc72c6 --- /dev/null +++ b/frontend/src/components/CoachNavBar.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/layouts/CoachLayout.vue b/frontend/src/layouts/CoachLayout.vue new file mode 100644 index 0000000..f21fd52 --- /dev/null +++ b/frontend/src/layouts/CoachLayout.vue @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index bafe606..f0c5bbe 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,7 +1,9 @@ import { createRouter, createWebHistory } from 'vue-router' import { useAuthStore } from '../stores/auth' +import { useCoachAuthStore } from '../stores/coachAuth' const routes = [ + // Member { path: '/', component: () => import('../views/HomeView.vue') }, { path: '/courses', component: () => import('../views/CoursesView.vue') }, { path: '/courses/:id', component: () => import('../views/CourseDetailView.vue') }, @@ -9,6 +11,22 @@ const routes = [ { 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 } }, + + // Coach (public) + { path: '/coach/login', component: () => import('../views/coach/LoginView.vue') }, + { path: '/coach/register', component: () => import('../views/coach/RegisterView.vue') }, + // Coach (protected) — wrapped in CoachLayout + { + 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') }, + ], + }, ] const router = createRouter({ @@ -17,10 +35,15 @@ const router = createRouter({ }) router.beforeEach((to) => { - const auth = useAuthStore() + const auth = useAuthStore() + const coachAuth = useCoachAuthStore() + if (to.meta.requiresAuth && !auth.isLoggedIn) { return { path: '/login' } } + if (to.meta.requiresCoach && !coachAuth.isLoggedIn) { + return { path: '/coach/login' } + } }) export default router diff --git a/frontend/src/stores/coachAuth.js b/frontend/src/stores/coachAuth.js new file mode 100644 index 0000000..769d18b --- /dev/null +++ b/frontend/src/stores/coachAuth.js @@ -0,0 +1,38 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import coachApi from '../api/coachAxios' + +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 + } + } + + function setAuth(userData, tokenValue) { + user.value = userData + token.value = tokenValue + localStorage.setItem('coach_token', tokenValue) + localStorage.setItem('coach_user', JSON.stringify(userData)) + } + + async function logout() { + try { + await coachApi.post('/provider/logout') + } catch {} + user.value = null + token.value = null + localStorage.removeItem('coach_token') + localStorage.removeItem('coach_user') + } + + return { user, token, isLoggedIn, init, setAuth, logout } +}) diff --git a/frontend/src/views/coach/DashboardView.vue b/frontend/src/views/coach/DashboardView.vue new file mode 100644 index 0000000..4bb4521 --- /dev/null +++ b/frontend/src/views/coach/DashboardView.vue @@ -0,0 +1,113 @@ + + + diff --git a/frontend/src/views/coach/LoginView.vue b/frontend/src/views/coach/LoginView.vue new file mode 100644 index 0000000..696413c --- /dev/null +++ b/frontend/src/views/coach/LoginView.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/views/coach/OfferFormView.vue b/frontend/src/views/coach/OfferFormView.vue new file mode 100644 index 0000000..a27bc6a --- /dev/null +++ b/frontend/src/views/coach/OfferFormView.vue @@ -0,0 +1,174 @@ + + +