From 4931868190586e071f0e2b1c53ed0510a417a36b Mon Sep 17 00:00:00 2001 From: Muhmd <149331205+ByMuhmd@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:57:08 +0300 Subject: [PATCH] Fixes --- app/(main)/worker/booking/[id]/ClientPage.tsx | 15 +- app/client/addresses/edit/[id]/ClientPage.tsx | 10 +- app/client/addresses/new/page.tsx | 10 +- app/client/bundle/confirm/page.tsx | 298 ++++++++++++++++++ app/client/bundle/page.tsx | 123 ++++++++ app/client/emergency/page.tsx | 15 +- app/client/home/page.tsx | 41 ++- app/client/order/success/page.tsx | 4 +- app/client/orders/page.tsx | 70 +++- .../rate-review/[bookingId]/ClientPage.tsx | 12 +- app/client/trust-board/page.tsx | 166 ++++++++++ components/shared/CapacitorListener.tsx | 17 + hooks/useCategoryWorkers.ts | 4 +- hooks/useClientBooking.ts | 58 +++- hooks/useClientHome.ts | 10 +- hooks/useHome.ts | 2 +- lib/supabase/booking-payments.ts | 2 + lib/supabase/schema.sql | 79 ++++- 18 files changed, 878 insertions(+), 58 deletions(-) create mode 100644 app/client/bundle/confirm/page.tsx create mode 100644 app/client/bundle/page.tsx create mode 100644 app/client/trust-board/page.tsx diff --git a/app/(main)/worker/booking/[id]/ClientPage.tsx b/app/(main)/worker/booking/[id]/ClientPage.tsx index ca48995..c51b15b 100644 --- a/app/(main)/worker/booking/[id]/ClientPage.tsx +++ b/app/(main)/worker/booking/[id]/ClientPage.tsx @@ -7,6 +7,7 @@ import { BookingStatusBadge } from '@/components/ui/BookingStatusBadge' import { useAuth } from '@/contexts/AuthContext' import { createClient } from '@/lib/supabase/client' import { createNotification } from '@/lib/notifications' +import { getActiveSubscription } from '@/lib/supabase/subscriptions' import { Geolocation } from '@capacitor/geolocation' import dynamic from 'next/dynamic' const LeafletTrackingMap = dynamic( @@ -168,12 +169,24 @@ export default function WorkerBookingDetailsPage() { timestamp: new Date().toISOString() } + const { data: workerProfile } = await supabase.from('profiles').select('rating').eq('id', profile?.id).single() + const clientSub = await getActiveSubscription(supabase, booking.client_id) + + let warrantyDays = 30 + if (workerProfile?.rating && workerProfile.rating >= 4.5) warrantyDays += 30 + if (clientSub?.plan_type === 'premium' || clientSub?.plan_type === 'master') warrantyDays += 30 + + const warrantyExpiresAt = new Date() + warrantyExpiresAt.setDate(warrantyExpiresAt.getDate() + warrantyDays) + const { error } = await supabase .from('bookings') .update({ status: 'completed', tracking_status: 'completed', - status_history: [...currentHistory, newHistoryEntry] + status_history: [...currentHistory, newHistoryEntry], + warranty_days: warrantyDays, + warranty_expires_at: warrantyExpiresAt.toISOString() }) .eq('id', booking.id) if (error) throw error diff --git a/app/client/addresses/edit/[id]/ClientPage.tsx b/app/client/addresses/edit/[id]/ClientPage.tsx index ae9c1d7..68300ca 100644 --- a/app/client/addresses/edit/[id]/ClientPage.tsx +++ b/app/client/addresses/edit/[id]/ClientPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, use } from 'react' import { useRouter } from 'next/navigation' -import { MapPin } from 'lucide-react' +import { MapPin, Home, Briefcase } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { useAuth } from '@/contexts/AuthContext' import { PageHeader } from '@/components/ui/PageHeader' @@ -27,10 +27,10 @@ export default function EditAddressPage({ params }: { params: Promise<{ id: stri const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(true) - const types: { key: AddressType; label: string; icon: string }[] = [ - { key: 'home', label: 'المنزل', icon: '🏠' }, - { key: 'work', label: 'العمل', icon: '💼' }, - { key: 'other', label: 'أخرى', icon: '📍' }, + const types: { key: AddressType; label: string; icon: React.ReactNode }[] = [ + { key: 'home', label: 'المنزل', icon: }, + { key: 'work', label: 'العمل', icon: }, + { key: 'other', label: 'أخرى', icon: }, ] useEffect(() => { diff --git a/app/client/addresses/new/page.tsx b/app/client/addresses/new/page.tsx index 4a19771..72a464c 100644 --- a/app/client/addresses/new/page.tsx +++ b/app/client/addresses/new/page.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react' import { useRouter } from 'next/navigation' -import { MapPin } from 'lucide-react' +import { MapPin, Home, Briefcase } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { useAuth } from '@/contexts/AuthContext' import { PageHeader } from '@/components/ui/PageHeader' @@ -25,10 +25,10 @@ export default function AddNewAddressPage() { const [notes, setNotes] = useState('') const [saving, setSaving] = useState(false) - const types: { key: AddressType; label: string; icon: string }[] = [ - { key: 'home', label: 'المنزل', icon: '🏠' }, - { key: 'work', label: 'العمل', icon: '💼' }, - { key: 'other', label: 'أخرى', icon: '📍' }, + const types: { key: AddressType; label: string; icon: React.ReactNode }[] = [ + { key: 'home', label: 'المنزل', icon: }, + { key: 'work', label: 'العمل', icon: }, + { key: 'other', label: 'أخرى', icon: }, ] const handleSave = async (e: React.FormEvent) => { diff --git a/app/client/bundle/confirm/page.tsx b/app/client/bundle/confirm/page.tsx new file mode 100644 index 0000000..7b162fa --- /dev/null +++ b/app/client/bundle/confirm/page.tsx @@ -0,0 +1,298 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { ArrowLeft, MapPin, Calendar, Clock, Star, CheckCircle2 } from 'lucide-react' +import { createClient } from '@/lib/supabase/client' + +const SERVICES = [ + { id: 'painting', name: 'دهان', basePrice: 150 }, + { id: 'carpentry', name: 'نجارة', basePrice: 150 }, + { id: 'plumbing', name: 'سباكة', basePrice: 150 }, + { id: 'electricity', name: 'كهرباء', basePrice: 150 }, +] + +interface BundleData { + id: string + services: string[] + date: string +} + +interface AssignedWorker { + serviceId: string + serviceName: string + time: string + worker: { + id: string + full_name: string | null + avatar_url: string | null + rating: number | null + } | null +} + +export default function BundleConfirmPage() { + const router = useRouter() + const [bundleData, setBundleData] = useState(null) + const [assignments, setAssignments] = useState([]) + const [loading, setLoading] = useState(true) + const [booking, setBooking] = useState(false) + + // Addresses + const [addresses, setAddresses] = useState([]) + const [selectedAddressId, setSelectedAddressId] = useState('') + + useEffect(() => { + const data = sessionStorage.getItem('pendingBundle') + if (!data) { + router.push('/client/bundle') + return + } + + const parsed = JSON.parse(data) + setBundleData(parsed) + loadData(parsed) + }, [router]) + + const loadData = async (data: BundleData) => { + setLoading(true) + const supabase = createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + router.push('/login') + return + } + + // Load addresses + const { data: addrData } = await supabase.from('addresses').select('*').eq('user_id', user.id) + if (addrData && addrData.length > 0) { + setAddresses(addrData) + setSelectedAddressId(addrData[0].id) + } + + // Auto-assign workers + const newAssignments: AssignedWorker[] = [] + let currentHour = 10 // Start at 10 AM + + for (const serviceId of data.services) { + const serviceInfo = SERVICES.find(s => s.id === serviceId) + if (!serviceInfo) continue + + // Find best available worker + const { data: workers } = await supabase + .from('profiles') + .select('id, full_name, avatar_url, rating') + .eq('role', 'worker') + .eq('verified', true) + .eq('is_available', true) + .eq('profession', serviceInfo.name) + .order('rating', { ascending: false }) + .limit(1) + + const assignedWorker = workers && workers.length > 0 ? workers[0] : null + + const timeString = `${currentHour.toString().padStart(2, '0')}:00` + + newAssignments.push({ + serviceId, + serviceName: serviceInfo.name, + time: timeString, + worker: assignedWorker + }) + + currentHour += 2 // 2 hours interval + } + + setAssignments(newAssignments) + setLoading(false) + } + + const handleConfirm = async () => { + if (!bundleData || assignments.length === 0) return + if (!selectedAddressId) { + alert('يرجى إضافة عنوان أولاً') + return + } + + setBooking(true) + const supabase = createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return + + const address = addresses.find(a => a.id === selectedAddressId) + const addressString = address ? `${address.city}، ${address.street}` : 'عنوان العميل' + + // Calculate discounted price per service + const basePrice = 150 + const discountedPrice = basePrice * 0.85 // 15% discount + + const bundleId = bundleData.id + const promises = assignments.map(assignment => { + // If no worker found for a specific profession, we still create an unassigned booking (or fail?) + // We'll create it as pending if worker is null, or confirmed if assigned + return supabase.from('bookings').insert({ + client_id: user.id, + worker_id: assignment.worker?.id || null, // Might be null if no worker is currently available + service_name: `باقة صيانة: ${assignment.serviceName}`, + status: assignment.worker ? 'confirmed' : 'pending', + price: discountedPrice, + payment_method: 'cash', + appointment_date: bundleData.date, + appointment_time: assignment.time, + address: addressString, + is_bundle: true, + bundle_id: bundleId, + notes: `جزء من باقة صيانة مجمعة` + }) + }) + + const results = await Promise.all(promises) + const errors = results.filter(r => r.error) + + if (errors.length > 0) { + console.error(errors) + alert('حدث خطأ أثناء حجز الباقة. يرجى المحاولة مرة أخرى.') + setBooking(false) + return + } + + sessionStorage.removeItem('pendingBundle') + router.push('/client/orders') + } + + if (loading) { + return ( +
+
+
+ ) + } + + const totalBasePrice = assignments.length * 150 + const discount = totalBasePrice * 0.15 + const finalPrice = totalBasePrice - discount + + return ( +
+
+
+ +

تأكيد الباقة

+
+
+
+ +
+ {/* Date Summary */} +
+
+
+ +
+
+

تاريخ التنفيذ

+

{bundleData?.date}

+
+
+
+ + {/* Schedule / Assignments */} +
+

الجدول الزمني للحرفيين

+
+ {assignments.map((assignment, index) => ( +
+
+
+
+
+

{assignment.serviceName}

+
+ + {assignment.time} +
+
+ {assignment.worker ? ( +
+ +
+

{assignment.worker.full_name}

+

+ {assignment.worker.rating || '4.9'} +

+
+
+ ) : ( + + جاري البحث عن حرفي + + )} +
+
+
+ ))} +
+
+ + {/* Address */} +
+

عنوان التنفيذ

+ {addresses.length > 0 ? ( + + ) : ( +
+

لم تقم بإضافة أي عنوان بعد

+ +
+ )} +
+ + {/* Pricing */} +
+

+ + ملخص الدفع +

+
+
+ التكلفة الأساسية ({assignments.length} تخصصات) + {totalBasePrice} ج.م +
+
+ خصم الباقة المجمعة (١٥٪) + - {discount} ج.م +
+
+
+ الإجمالي المطلوب + {finalPrice} ج.م +
+
+
+
+ +
+
+ +
+
+
+ ) +} diff --git a/app/client/bundle/page.tsx b/app/client/bundle/page.tsx new file mode 100644 index 0000000..e23ce97 --- /dev/null +++ b/app/client/bundle/page.tsx @@ -0,0 +1,123 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { ArrowLeft, CheckCircle2, Circle } from 'lucide-react' + +import ToolSilhouettes from '@/components/shared/ToolSilhouettes' + +const SERVICES = [ + { id: 'painting', name: 'دهان', basePrice: 150 }, + { id: 'carpentry', name: 'نجارة', basePrice: 150 }, + { id: 'plumbing', name: 'سباكة', basePrice: 150 }, + { id: 'electricity', name: 'كهرباء', basePrice: 150 }, +] + +export default function BundlePage() { + const router = useRouter() + const [selectedServices, setSelectedServices] = useState([]) + const [date, setDate] = useState('') + + const handleToggle = (id: string) => { + if (selectedServices.includes(id)) { + setSelectedServices(selectedServices.filter(s => s !== id)) + } else { + setSelectedServices([...selectedServices, id]) + } + } + + const handleNext = () => { + if (selectedServices.length < 2) return alert('يجب اختيار تخصصين على الأقل لتكوين باقة.') + if (!date) return alert('يرجى اختيار تاريخ الباقة.') + + const bundleId = crypto.randomUUID() + // Storing bundle details in session storage before checkout + sessionStorage.setItem('pendingBundle', JSON.stringify({ + id: bundleId, + services: selectedServices, + date, + })) + router.push('/client/bundle/confirm') + } + + return ( +
+ +
+
+ +

إنشاء باقة صيانة

+
+
+
+ +
+
+

كيف تعمل الباقات؟

+
    +
  • اختر تخصصين أو أكثر من القائمة أدناه.
  • +
  • سيتم جدولة الحرفيين بشكل متتابع (بفارق ساعتين بين كل تخصص).
  • +
  • تحصل فوراً على خصم ١٥٪ من إجمالي التكلفة.
  • +
+
+ +

اختر التخصصات (تخصصين على الأقل)

+
+ {SERVICES.map(service => { + const isSelected = selectedServices.includes(service.id) + return ( + + ) + })} +
+ +
+

تاريخ التنفيذ

+ setDate(e.target.value)} + className="w-full bg-[#0F172A]/60 border border-white/10 rounded-xl p-4 text-white placeholder-white/30 outline-none focus:border-purple-500 transition" + style={{ colorScheme: 'dark' }} + /> +
+
+ +
+
+
+ التخصصات المحددة: {selectedServices.length} + {selectedServices.length >= 2 && ( + مؤهل للخصم (١٥٪) + )} +
+ +
+
+
+ ) +} diff --git a/app/client/emergency/page.tsx b/app/client/emergency/page.tsx index 2f7481f..636f8ca 100644 --- a/app/client/emergency/page.tsx +++ b/app/client/emergency/page.tsx @@ -103,7 +103,7 @@ export default function EmergencyPage() { const startSOSSearch = async () => { if (!selectedType) return setStep('searching') - setCountdown(60) + setCountdown(600) const supabase = createClient() const { data: { user } } = await supabase.auth.getUser() @@ -167,12 +167,19 @@ export default function EmergencyPage() { channelRef.current = channel - timerRef.current = setInterval(async () => { + timerRef.current = setInterval(() => { setCountdown(prev => { if (prev <= 1) { if (timerRef.current) clearInterval(timerRef.current) supabase.removeChannel(channel) - supabase.from('emergencies').update({ status: 'cancelled' }).eq('id', emergency.id) + supabase.from('emergencies').update({ status: 'cancelled' }).eq('id', emergency.id).then(async () => { + const { data: profile } = await supabase.from('profiles').select('wallet_balance').eq('id', user.id).single() + if (profile) { + await supabase.from('profiles').update({ wallet_balance: (profile.wallet_balance || 0) + 50 }).eq('id', user.id) + await supabase.from('transactions').insert({ user_id: user.id, type: 'deposit', amount: 50, description: 'تعويض تلقائي لتأخر استجابة طلب طوارئ (أكثر من 10 دقائق)' }) + alert('نعتذر لتأخر الاستجابة. تم إضافة 50 ج.م رصيد مجاني لمحفظتك كتعويض.') + } + }) setStep('fallback') return 0 } @@ -226,7 +233,7 @@ export default function EmergencyPage() { worker_id: workerId, service_name: selectedType.label, status: 'confirmed', - price: 200, + price: 240, // 200 base + 20% emergency surcharge is_emergency: true, emergency_type: selectedType.id, notes: description || 'طلب طوارئ فوري مباشر', diff --git a/app/client/home/page.tsx b/app/client/home/page.tsx index 4603743..3bdb418 100644 --- a/app/client/home/page.tsx +++ b/app/client/home/page.tsx @@ -7,7 +7,7 @@ import Image from 'next/image' import { useAuth } from '@/contexts/AuthContext' import { createClient } from '@/lib/supabase/client' import { useClientHome } from '@/hooks/useClientHome' -import { Bell, Search, Star } from 'lucide-react' +import { Bell, Search, Star, Award } from 'lucide-react' import ToolSilhouettes from '@/components/shared/ToolSilhouettes' const FigmaPainting = ({ size }: { size?: number }) => @@ -81,7 +81,7 @@ export default function ClientHomePage() {

مرحباً،

-

{displayName} 👋

+

{displayName}

@@ -118,8 +118,41 @@ export default function ClientHomePage() {

احجز حرفي موثوق خلال دقائق

-
- احجز الآن +
+ احجز خدمة مفردة +
+
+
+ + + +
+
+
+ + خصم ١٥٪ + +
+
+

باقات الصيانة المجمعة

+

احجز أكثر من تخصص في نفس الوقت واحصل على خصم فوري

+
+ إنشاء باقة +
+
+
+ + + +
+
+ +
+
+

لوحة الثقة المحلية

+

اكتشف أفضل الحرفيين وأعلاهم تقييماً في منطقتك

+
+ عرض المتصدرين
diff --git a/app/client/order/success/page.tsx b/app/client/order/success/page.tsx index 82215f2..19c4697 100644 --- a/app/client/order/success/page.tsx +++ b/app/client/order/success/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -import { ArrowLeft, Clock, MapPin, Home } from 'lucide-react' +import { ArrowLeft, Clock, MapPin, Home, Star } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import dynamic from 'next/dynamic' const LeafletTrackingMap = dynamic( @@ -174,7 +174,7 @@ function SuccessContent() {

{worker.full_name || 'حرفي'}

{worker.profession || 'حرفي'} • - ★ {worker.rating?.toFixed(1) || '4.9'} + {worker.rating?.toFixed(1) || '4.9'}
diff --git a/app/client/orders/page.tsx b/app/client/orders/page.tsx index e685fa1..42e6841 100644 --- a/app/client/orders/page.tsx +++ b/app/client/orders/page.tsx @@ -13,6 +13,9 @@ interface Booking { status: string price: number worker_id: string + service_name: string + address: string + warranty_expires_at: string | null worker: { full_name: string avatar_url: string | null @@ -73,6 +76,39 @@ export default function OrdersPage() { fetchBookings() }, []) + const handleRecurrence = async (booking: Booking) => { + const supabase = createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return + + const confirmRecurrence = window.confirm('هل تود طلب زيارة صيانة مجانية بسبب تكرار المشكلة؟') + if (!confirmRecurrence) return + + const { error } = await supabase + .from('bookings') + .insert({ + client_id: user.id, + worker_id: booking.worker_id, + service_name: booking.service_name || 'تكرار المشكلة', + status: 'pending', + price: 0, + notes: 'زيارة ضمن الضمان بسبب تكرار المشكلة', + payment_method: 'cash', + appointment_date: new Date().toISOString().split('T')[0], + appointment_time: new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }), + address: booking.address || 'العنوان غير محدد', + is_recurrence: true, + original_booking_id: booking.id + }) + + if (error) { + alert('تعذر إنشاء طلب الضمان: ' + error.message) + } else { + alert('تم إرسال طلب الزيارة المجانية للحرفي.') + window.location.reload() + } + } + const filteredBookings = bookings.filter((b) => STATUS_MAP[activeTab].includes(b.status) ) @@ -197,19 +233,27 @@ export default function OrdersPage() { {/* View Details / Warranty */}
- {clientPlan === 'estate' && (booking.status === 'completed' || booking.status === 'closed') ? ( - - ) : ( -
- )} + {(() => { + if (booking.status !== 'completed' && booking.status !== 'closed') return
+ const now = new Date() + const expiresAt = booking.warranty_expires_at ? new Date(booking.warranty_expires_at) : null + const isWarrantyActive = expiresAt && expiresAt > now + + if (isWarrantyActive) { + return ( + + ) + } + return
+ })()} عرض التفاصيل diff --git a/app/client/rate-review/[bookingId]/ClientPage.tsx b/app/client/rate-review/[bookingId]/ClientPage.tsx index 1be2a09..6440bb4 100644 --- a/app/client/rate-review/[bookingId]/ClientPage.tsx +++ b/app/client/rate-review/[bookingId]/ClientPage.tsx @@ -67,16 +67,8 @@ export default function RateReviewPage() { return } - // Update worker average rating - const { data: allReviews } = await supabase - .from('reviews') - .select('rating') - .eq('craftsman_id', workerId) - - if (allReviews && allReviews.length > 0) { - const avg = allReviews.reduce((sum, rev) => sum + rev.rating, 0) / allReviews.length - await supabase.from('profiles').update({ rating: parseFloat(avg.toFixed(1)) }).eq('id', workerId) - } + // Update worker average rating via RPC + await supabase.rpc('update_worker_rating', { p_worker_id: workerId }) // Mark booking as closed await supabase.from('bookings').update({ status: 'closed' }).eq('id', bookingId) diff --git a/app/client/trust-board/page.tsx b/app/client/trust-board/page.tsx new file mode 100644 index 0000000..7726ac2 --- /dev/null +++ b/app/client/trust-board/page.tsx @@ -0,0 +1,166 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { ArrowLeft, MapPin, Award, Star, CheckCircle } from 'lucide-react' +import { createClient } from '@/lib/supabase/client' +import Image from 'next/image' + +interface Craftsman { + id: string + full_name: string + avatar_url: string | null + profession: string | null + rating: number | null + completed_orders: number | null + governorate: string | null + area: string | null +} + +const MEDALS = [ + { rank: 1, label: 'الأول على المنطقة', color: 'text-yellow-400', bg: 'bg-yellow-400/20', border: 'border-yellow-400' }, + { rank: 2, label: 'الثاني على المنطقة', color: 'text-gray-300', bg: 'bg-gray-300/20', border: 'border-gray-300' }, + { rank: 3, label: 'الثالث على المنطقة', color: 'text-amber-600', bg: 'bg-amber-600/20', border: 'border-amber-600' }, +] + +export default function TrustBoardPage() { + const router = useRouter() + const [leaders, setLeaders] = useState([]) + const [loading, setLoading] = useState(true) + const [userLocation, setUserLocation] = useState<{ governorate: string, area: string } | null>(null) + + useEffect(() => { + const fetchLeaderboard = async () => { + const supabase = createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return + + // Get user location + const { data: profile } = await supabase + .from('profiles') + .select('governorate, area') + .eq('id', user.id) + .single() + + if (profile && profile.governorate) { + setUserLocation({ governorate: profile.governorate, area: profile.area || '' }) + + // Fetch top workers in this area + const { data: workers } = await supabase + .from('profiles') + .select('id, full_name, avatar_url, profession, rating, completed_orders, governorate, area') + .eq('role', 'worker') + .eq('verified', true) + .eq('governorate', profile.governorate) + .eq('area', profile.area || '') + .order('rating', { ascending: false }) + .order('completed_orders', { ascending: false }) + .limit(10) + + if (workers) { + setLeaders(workers as Craftsman[]) + } + } + setLoading(false) + } + + fetchLeaderboard() + }, []) + + return ( +
+
+
+ +

لوحة الثقة المحلية

+
+
+
+ +
+
+ +

أفضل الحرفيين في منطقتك

+ {userLocation ? ( +

+ {userLocation.governorate}، {userLocation.area} +

+ ) : ( +

جاري تحديد المنطقة...

+ )} +

يتم تحديث الترتيب شهرياً بناءً على تقييمات العملاء وعدد الطلبات المكتملة.

+
+ + {loading ? ( +
+
+
+ ) : leaders.length === 0 ? ( +
+

لا يوجد حرفيين كافيين في هذه المنطقة حتى الآن.

+
+ ) : ( +
+ {leaders.map((craftsman, index) => { + const medal = MEDALS[index] + return ( +
router.push(`/client/craftsman/${craftsman.id}`)} + className={`relative bg-[#0F172A]/80 border ${medal ? medal.border : 'border-white/5'} rounded-2xl p-4 flex items-center gap-4 cursor-pointer hover:bg-[#1E293B] transition`} + > +
+
+ {craftsman.avatar_url ? ( + + ) : ( +
+ {craftsman.full_name?.charAt(0)} +
+ )} +
+ {medal && ( +
+ {index + 1} +
+ )} +
+ +
+
+

{craftsman.full_name}

+ +
+

{craftsman.profession}

+
+ + + {craftsman.rating?.toFixed(1) || '5.0'} + + + {craftsman.completed_orders || 0} طلب مكتمل +
+
+ + {medal && ( +
+ + {medal.label} +
+ )} + {!medal && ( +
+ #{index + 1} +
+ )} +
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/components/shared/CapacitorListener.tsx b/components/shared/CapacitorListener.tsx index 49db222..a2e20a3 100644 --- a/components/shared/CapacitorListener.tsx +++ b/components/shared/CapacitorListener.tsx @@ -26,6 +26,23 @@ export function CapacitorListener() { } }) + CapacitorApp.addListener('backButton', ({ canGoBack }) => { + const path = window.location.pathname + const isRootPage = + path === '/' || + path === '/onboarding' || + path === '/login' || + path === '/client/home' || + path === '/worker/home' || + path === '/admin' + + if (isRootPage || !canGoBack) { + CapacitorApp.exitApp() + } else { + window.history.back() + } + }) + return () => { CapacitorApp.removeAllListeners() } diff --git a/hooks/useCategoryWorkers.ts b/hooks/useCategoryWorkers.ts index 9a37f1c..409a09c 100644 --- a/hooks/useCategoryWorkers.ts +++ b/hooks/useCategoryWorkers.ts @@ -97,7 +97,9 @@ export function useCategoryWorkers(categoryId: string) { return scoreB - scoreA } if (sortBy === 'rating') { - return (b.rating || 0) - (a.rating || 0) + const trustMetricA = (a.rating || 0) * 1000 + (a.completed_orders || 0) + const trustMetricB = (b.rating || 0) * 1000 + (b.completed_orders || 0) + return trustMetricB - trustMetricA } else if (sortBy === 'price_asc') { return (a.min_price || 999999) - (b.min_price || 999999) } else { diff --git a/hooks/useClientBooking.ts b/hooks/useClientBooking.ts index fbd9744..b1aeb4e 100644 --- a/hooks/useClientBooking.ts +++ b/hooks/useClientBooking.ts @@ -19,6 +19,11 @@ export interface BookingInput { images?: string[] } +export interface BundleBookingInput { + services: BookingInput[] + bundle_id: string +} + export function useClientBooking() { const supabase = createClient() const { profile } = useAuth() @@ -69,16 +74,53 @@ export function useClientBooking() { return booking }, [supabase, profile]) + const createBundleBooking = useCallback(async (data: BundleBookingInput) => { + if (!profile) { setError('يجب تسجيل الدخول أولاً'); return null } + setSubmitting(true) + setError('') + + const insertions = data.services.map(s => ({ + client_id: profile.id, + worker_id: s.worker_id, + craftsman_id: s.worker_id, + service_name: s.service_name, + appointment_date: s.appointment_date, + appointment_time: s.appointment_time, + address: s.address, + price: s.total_amount, + notes: s.notes || null, + payment_method: s.payment_method || 'cash', + status: 'pending', + bundle_id: data.bundle_id, + is_bundle: true + })) + + const { data: bookings, error: err } = await supabase.from('bookings').insert(insertions).select('id') + + if (err) { setError(err.message); setSubmitting(false); return null } + if (bookings && profile) { + data.services.forEach(s => { + createNotification(s.worker_id, 'طلب خدمة ضمن باقة', `لديك طلب خدمة جديد (باقة صيانة) من ${profile.full_name || 'عميل'}`) + }) + } + setSubmitting(false) + return bookings + }, [supabase, profile]) + const cancelBooking = useCallback(async (bookingId: string) => { setSubmitting(true) setError('') - const { data: booking } = await supabase - .from('bookings') - .select('worker_id') - .eq('id', bookingId) - .single() - const { error: err } = await supabase.from('bookings').update({ status: 'cancelled' }).eq('id', bookingId) - if (err) { setError(err.message); setSubmitting(false); return false } + + // First try the RPC which handles the cancellation fee logic + const { error: rpcError } = await supabase.rpc('cancel_booking_with_fee', { p_booking_id: bookingId }) + + if (rpcError) { + console.warn('RPC failed or not found, falling back to standard cancellation', rpcError) + const { error: err } = await supabase.from('bookings').update({ status: 'cancelled' }).eq('id', bookingId) + if (err) { setError(err.message); setSubmitting(false); return false } + } + + const { data: booking } = await supabase.from('bookings').select('worker_id').eq('id', bookingId).single() if (booking) { createNotification(booking.worker_id, 'تم إلغاء الحجز من قبل العميل', `قام ${profile?.full_name || 'العميل'} بإلغاء الحجز`) } @@ -86,5 +128,5 @@ export function useClientBooking() { return true }, [supabase, profile]) - return { createBooking, cancelBooking, submitting, error } + return { createBooking, createBundleBooking, cancelBooking, submitting, error } } diff --git a/hooks/useClientHome.ts b/hooks/useClientHome.ts index 5cb1a6a..804bfb2 100644 --- a/hooks/useClientHome.ts +++ b/hooks/useClientHome.ts @@ -81,15 +81,19 @@ export function useClientHome() { const scoreB = getProximityScore(b) if (scoreA !== scoreB) return scoreB - scoreA - // Then by rating - return (b.rating || 0) - (a.rating || 0) + // Then by rating and completed orders + const trustMetricA = (a.rating || 0) * 1000 + (a.completed_orders || 0) + const trustMetricB = (b.rating || 0) * 1000 + (b.completed_orders || 0) + return trustMetricB - trustMetricA }) } else { result.sort((a, b) => { const pA = planScore[a.workerPlan as keyof typeof planScore] || 1 const pB = planScore[b.workerPlan as keyof typeof planScore] || 1 if (pA !== pB) return pB - pA - return (b.rating || 0) - (a.rating || 0) + const trustMetricA = (a.rating || 0) * 1000 + (a.completed_orders || 0) + const trustMetricB = (b.rating || 0) * 1000 + (b.completed_orders || 0) + return trustMetricB - trustMetricA }) } setWorkers(result.slice(0, 5)) diff --git a/hooks/useHome.ts b/hooks/useHome.ts index 8914296..dd6e961 100644 --- a/hooks/useHome.ts +++ b/hooks/useHome.ts @@ -117,7 +117,7 @@ export function useHome() { worker_id: profile.id, service_name: 'خدمة طوارئ عاجلة', status: 'confirmed', - price: 200, + price: 240, // 200 base + 20% emergency surcharge is_emergency: true, emergency_type: emergency.type, notes: emergency.description || 'طلب طوارئ عاجل', diff --git a/lib/supabase/booking-payments.ts b/lib/supabase/booking-payments.ts index 564a741..126b57b 100644 --- a/lib/supabase/booking-payments.ts +++ b/lib/supabase/booking-payments.ts @@ -50,11 +50,13 @@ export async function processBookingCompletion(supabase: SupabaseClient, booking if (booking.payment_method === 'cash') { const newBalance = currentBalance - fee + const newTotalEarnings = currentTotalEarnings + workerEarnings await supabase .from('profiles') .update({ wallet_balance: newBalance, + total_earnings: newTotalEarnings, completed_orders: currentCompletedOrders + 1 }) .eq('id', booking.worker_id) diff --git a/lib/supabase/schema.sql b/lib/supabase/schema.sql index af517d2..31b6311 100644 --- a/lib/supabase/schema.sql +++ b/lib/supabase/schema.sql @@ -52,10 +52,14 @@ CREATE POLICY "Users can read own notifications" ON public.notifications FOR SELECT USING (auth.uid() = user_id); -CREATE POLICY "Users can insert notifications" +CREATE POLICY "System can insert notifications" ON public.notifications FOR INSERT WITH CHECK (true); +CREATE POLICY "Users can update their own notifications" + ON public.notifications FOR UPDATE + USING (auth.uid() = user_id); + -- ============================================ -- 4. CONVERSATIONS TABLE -- ============================================ @@ -321,3 +325,76 @@ CREATE TABLE IF NOT EXISTS public.admin_messages ( ALTER TABLE public.admin_messages ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS "Allow all admin_messages" ON public.admin_messages; CREATE POLICY "Allow all admin_messages" ON public.admin_messages FOR ALL USING (true); + +-- ============================================ +-- 18. ADDITIONAL COLUMNS FOR BOOKINGS (BUNDLES) +-- ============================================ +ALTER TABLE public.bookings ADD COLUMN IF NOT EXISTS bundle_id UUID; +ALTER TABLE public.bookings ADD COLUMN IF NOT EXISTS is_bundle BOOLEAN DEFAULT false; + +-- ============================================ +-- 19. RPC FOR CANCELLING BOOKING WITH FEE +-- ============================================ +CREATE OR REPLACE FUNCTION public.cancel_booking_with_fee( + p_booking_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_client_id UUID; + v_tracking_status TEXT; + v_balance NUMERIC; +BEGIN + -- Get booking details + SELECT client_id, tracking_status INTO v_client_id, v_tracking_status + FROM public.bookings + WHERE id = p_booking_id; + + IF NOT FOUND THEN + RETURN FALSE; + END IF; + + -- Update booking status to cancelled + UPDATE public.bookings SET status = 'cancelled' WHERE id = p_booking_id; + + -- Check if tracking_status is 'on_the_way', 'arrived', 'work_started' + IF v_tracking_status IN ('on_the_way', 'arrived', 'work_started') THEN + -- Deduct 50 EGP from client + UPDATE public.profiles + SET wallet_balance = COALESCE(wallet_balance, 0) - 50 + WHERE id = v_client_id; + + -- Log transaction + INSERT INTO public.transactions (user_id, type, amount, description, reference_type, reference_id) + VALUES (v_client_id, 'payment', -50, 'رسوم إلغاء حجز بعد تحرك الحرفي', 'booking', p_booking_id); + END IF; + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- ============================================ +-- 20. ADDITIONAL COLUMNS FOR BOOKINGS (WARRANTY & RECURRENCE) +-- ============================================ +ALTER TABLE public.bookings ADD COLUMN IF NOT EXISTS warranty_days INTEGER; +ALTER TABLE public.bookings ADD COLUMN IF NOT EXISTS warranty_expires_at TIMESTAMPTZ; +ALTER TABLE public.bookings ADD COLUMN IF NOT EXISTS is_recurrence BOOLEAN DEFAULT false; +ALTER TABLE public.bookings ADD COLUMN IF NOT EXISTS original_booking_id UUID REFERENCES public.bookings(id); + +-- ============================================ +-- 21. RPC FOR UPDATING WORKER RATING +-- ============================================ +CREATE OR REPLACE FUNCTION public.update_worker_rating(p_worker_id UUID) +RETURNS VOID AS $$ +DECLARE + v_avg_rating NUMERIC; +BEGIN + SELECT COALESCE(AVG(rating), 0) INTO v_avg_rating + FROM public.reviews + WHERE craftsman_id = p_worker_id; + + UPDATE public.profiles + SET rating = ROUND(v_avg_rating, 1) + WHERE id = p_worker_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +