@@ -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 && (
+
+ #{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;
+
+