diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index c3680be..071b6db 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -37,15 +37,33 @@ jobs: run: pnpm install - name: Build Next.js app - run: pnpm run build + run: | + # Move API routes out of the way because they cannot be statically exported + if [ -d "app/api" ]; then + mv app/api .temp-api-backup + fi + pnpm run build:capacitor env: - # Add any environment variables needed for the build here NODE_ENV: production - - - name: Sync Capacitor Android + + - name: Prepare Capacitor files run: | - mkdir -p android/app/src/main/assets/public - npx cap sync android + # Install capacitor CLI and core + pnpm add -D @capacitor/cli @capacitor/core @capacitor/android + + # Create a root capacitor config to bundle the static files + cat < capacitor.config.json + { + "appId": "com.hirfa.app", + "appName": "Hirfa", + "webDir": "out", + "bundledWebRuntime": false + } + EOF + + # Add and sync the android platform + rm -rf android # Remove the broken or incomplete android folder + npx cap add android - name: Build APK working-directory: ./android diff --git a/.gitignore b/.gitignore index 2e1d45a..53f3723 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,17 @@ node_modules/ .DS_Store *.swp *.swo + +# Next.js +out/ +build/ +.turbo/ + +# Capacitor / Android +android/.gradle +android/app/build +android/build +android/local.properties +android/.idea +android/**/*.iml +app-debug.apk diff --git a/@/components/ui/button.tsx b/@/components/ui/button.tsx deleted file mode 100644 index b033601..0000000 --- a/@/components/ui/button.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Button as ButtonPrimitive } from "@base-ui/react/button" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant = "default", - size = "default", - ...props -}: ButtonPrimitive.Props & VariantProps) { - return ( - - ) -} - -export { Button, buttonVariants } diff --git a/@/components/ui/input.tsx b/@/components/ui/input.tsx deleted file mode 100644 index 7d21bab..0000000 --- a/@/components/ui/input.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from "react" -import { Input as InputPrimitive } from "@base-ui/react/input" - -import { cn } from "@/lib/utils" - -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ) -} - -export { Input } diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json new file mode 100644 index 0000000..4822c2d --- /dev/null +++ b/android/app/src/main/assets/capacitor.config.json @@ -0,0 +1,9 @@ +{ + "appId": "com.hirfa.app", + "appName": "Hirfa", + "webDir": "out", + "server": { + "url": "https://hirfa-amber.vercel.app", + "cleartext": true + } +} diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json new file mode 100644 index 0000000..4881319 --- /dev/null +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -0,0 +1,10 @@ +[ + { + "pkg": "@capacitor/app", + "classpath": "com.capacitorjs.plugins.app.AppPlugin" + }, + { + "pkg": "@capacitor/local-notifications", + "classpath": "com.capacitorjs.plugins.localnotifications.LocalNotificationsPlugin" + } +] diff --git a/android/app/src/main/assets/public/cordova.js b/android/app/src/main/assets/public/cordova.js new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/assets/public/cordova_plugins.js b/android/app/src/main/assets/public/cordova_plugins.js new file mode 100644 index 0000000..e69de29 diff --git a/android/app/src/main/res/xml/config.xml b/android/app/src/main/res/xml/config.xml new file mode 100644 index 0000000..1b1b0e0 --- /dev/null +++ b/android/app/src/main/res/xml/config.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/capacitor-cordova-android-plugins/build.gradle b/android/capacitor-cordova-android-plugins/build.gradle new file mode 100644 index 0000000..b2e25dd --- /dev/null +++ b/android/capacitor-cordova-android-plugins/build.gradle @@ -0,0 +1,59 @@ +ext { + androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1' + cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '14.0.1' +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.13.0' + } +} + +apply plugin: 'com.android.library' + +android { + namespace = "capacitor.cordova.android.plugins" + compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36 + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36 + versionCode 1 + versionName "1.0" + } + lintOptions { + abortOnError = false + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 + } +} + +repositories { + google() + mavenCentral() + flatDir{ + dirs 'src/main/libs', 'libs' + } +} + +dependencies { + implementation fileTree(dir: 'src/main/libs', include: ['*.jar']) + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "org.apache.cordova:framework:$cordovaAndroidVersion" + // SUB-PROJECT DEPENDENCIES START + + // SUB-PROJECT DEPENDENCIES END +} + +// PLUGIN GRADLE EXTENSIONS START +apply from: "cordova.variables.gradle" +// PLUGIN GRADLE EXTENSIONS END + +for (def func : cdvPluginPostBuildExtras) { + func() +} \ No newline at end of file diff --git a/android/capacitor-cordova-android-plugins/cordova.variables.gradle b/android/capacitor-cordova-android-plugins/cordova.variables.gradle new file mode 100644 index 0000000..b806d8a --- /dev/null +++ b/android/capacitor-cordova-android-plugins/cordova.variables.gradle @@ -0,0 +1,7 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +ext { + cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24 + // Plugin gradle extensions can append to this to have code run at the end. + cdvPluginPostBuildExtras = [] + cordovaConfig = [:] +} \ No newline at end of file diff --git a/android/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml b/android/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml new file mode 100644 index 0000000..91d30c6 --- /dev/null +++ b/android/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/android/capacitor-cordova-android-plugins/src/main/java/.gitkeep b/android/capacitor-cordova-android-plugins/src/main/java/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/android/capacitor-cordova-android-plugins/src/main/res/.gitkeep b/android/capacitor-cordova-android-plugins/src/main/res/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/android/capacitor-cordova-android-plugins/src/main/res/.gitkeep @@ -0,0 +1 @@ + diff --git a/app-debug.apk b/app-debug.apk deleted file mode 100644 index 80c257e..0000000 Binary files a/app-debug.apk and /dev/null differ diff --git a/app/(main)/worker/booking/[id]/ClientPage.tsx b/app/(main)/worker/booking/[id]/ClientPage.tsx new file mode 100644 index 0000000..ca48995 --- /dev/null +++ b/app/(main)/worker/booking/[id]/ClientPage.tsx @@ -0,0 +1,393 @@ +'use client' +import React, { useState, useEffect, useCallback } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { ArrowRight, MapPin, Clock, CalendarDays, Phone, MessageSquare, CheckCircle2, Truck, Hammer, XCircle, Map as MapIcon, Image as ImageIcon } from 'lucide-react' +import { PageLoader } from '@/components/ui/PageLoader' +import { BookingStatusBadge } from '@/components/ui/BookingStatusBadge' +import { useAuth } from '@/contexts/AuthContext' +import { createClient } from '@/lib/supabase/client' +import { createNotification } from '@/lib/notifications' +import { Geolocation } from '@capacitor/geolocation' +import dynamic from 'next/dynamic' +const LeafletTrackingMap = dynamic( + () => import('@/components/shared/LeafletTrackingMap'), + { ssr: false } +) +export default function WorkerBookingDetailsPage() { + const { id } = useParams() + const router = useRouter() + const { profile } = useAuth() + const supabase = createClient() + const [booking, setBooking] = useState(null) + const [loading, setLoading] = useState(true) + const [watchId, setWatchId] = useState(null) + const [clientLat, setClientLat] = useState(30.0444) + const [clientLng, setClientLng] = useState(31.2357) + const [displayAddress, setDisplayAddress] = useState('عنوان غير محدد') + const [notesText, setNotesText] = useState('') + const [images, setImages] = useState([]) + const [statusNote, setStatusNote] = useState('') + const fetchBooking = useCallback(async () => { + setLoading(true) + const { data, error } = await supabase + .from('bookings') + .select('*, client:client_id(*)') + .eq('id', id) + .single() + if (error) { + console.error(error) + router.push('/worker/schedule') + return + } + if (data) { + setBooking(data) + let addr = data.address || 'عنوان غير محدد' + if (addr.includes('|')) { + const parts = addr.split('|') + addr = parts[0].trim() + const coordsStr = parts[1]?.trim() + if (coordsStr) { + const [latStr, lngStr] = coordsStr.split(',') + const parsedLat = parseFloat(latStr) + const parsedLng = parseFloat(lngStr) + if (!isNaN(parsedLat) && !isNaN(parsedLng)) { + setClientLat(parsedLat) + setClientLng(parsedLng) + } + } + } + setDisplayAddress(addr) + if (data.notes) { + if (data.notes.trim().startsWith('{')) { + try { + const parsed = JSON.parse(data.notes) + setNotesText(parsed.text || '') + if (Array.isArray(parsed.images)) setImages(parsed.images) + } catch (e) { + setNotesText(data.notes) + } + } else { + setNotesText(data.notes) + } + } + } + setLoading(false) + }, [id, supabase, router]) + useEffect(() => { + fetchBooking() + }, [fetchBooking]) + useEffect(() => { + return () => { + if (watchId) Geolocation.clearWatch({ id: watchId }) + } + }, [watchId]) + const stopTracking = async () => { + if (watchId) { + await Geolocation.clearWatch({ id: watchId }) + setWatchId(null) + } + } + const startTracking = async () => { + try { + const perm = await Geolocation.checkPermissions() + if (perm.location !== 'granted') { + const req = await Geolocation.requestPermissions() + if (req.location !== 'granted') { + alert('يجب الموافقة على صلاحية الموقع لتفعيل التتبع المباشر') + return + } + } + const wid = await Geolocation.watchPosition({ enableHighAccuracy: true }, (pos, err) => { + if (pos) { + supabase.from('bookings').update({ + craftsman_lat: pos.coords.latitude, + craftsman_lng: pos.coords.longitude + }).eq('id', id).then() + } + }) + setWatchId(wid) + } catch (e) { + console.error(e) + } + } + const openMaps = () => { + if (booking?.address?.includes('|')) { + const coordsStr = booking.address.split('|')[1]?.trim() + if (coordsStr) { + window.open(`https://maps.google.com/?q=${coordsStr}`) + return + } + } + window.open(`https://maps.google.com/?q=${encodeURIComponent(booking?.address || '')}`) + } + const updateTrackingStatus = async (nextStatus: string, label: string) => { + try { + const currentHistory = Array.isArray(booking?.status_history) ? booking.status_history : [] + const newHistoryEntry = { + tracking_status: nextStatus, + notes: statusNote.trim() || null, + timestamp: new Date().toISOString() + } + + const updates: any = { + tracking_status: nextStatus, + status_history: [...currentHistory, newHistoryEntry] + } + if (statusNote.trim()) { + updates.status_notes = statusNote.trim() + } + if (nextStatus === 'accepted' && booking.status === 'pending') { + updates.status = 'confirmed' + } + const { error } = await supabase.from('bookings').update(updates).eq('id', booking.id) + if (error) throw error + await createNotification( + booking.client_id, + 'تحديث حالة الطلب', + `حالة طلبك (${booking.service_name}) تغيرت إلى: ${label}` + ) + if (nextStatus === 'on_the_way') { + await startTracking() + } else if (['arrived', 'work_started', 'completed'].includes(nextStatus)) { + await stopTracking() + } + fetchBooking() + setStatusNote('') + } catch (err) { + alert('فشل تحديث الحالة') + } + } + const completeBooking = async () => { + try { + await stopTracking() + const { processBookingCompletion } = await import('@/lib/supabase/booking-payments') + await processBookingCompletion(supabase, booking.id) + const currentHistory = Array.isArray(booking?.status_history) ? booking.status_history : [] + const newHistoryEntry = { + tracking_status: 'completed', + timestamp: new Date().toISOString() + } + + const { error } = await supabase + .from('bookings') + .update({ + status: 'completed', + tracking_status: 'completed', + status_history: [...currentHistory, newHistoryEntry] + }) + .eq('id', booking.id) + if (error) throw error + await createNotification( + booking.client_id, + 'اكتملت الخدمة', + `تم إكمال خدمة ${booking.service_name} بنجاح.` + ) + fetchBooking() + } catch (err) { + alert('فشل إنهاء الطلب') + } + } + const cancelBooking = async () => { + try { + await stopTracking() + const { error } = await supabase + .from('bookings') + .update({ status: 'cancelled' }) + .eq('id', booking.id) + if (error) throw error + await createNotification( + booking.client_id, + 'تم إلغاء الموعد', + `تم إلغاء موعد خدمة ${booking.service_name} بواسطة الحرفي.` + ) + fetchBooking() + } catch (err) { + alert('فشل إلغاء الطلب') + } + } + if (loading) return + if (!booking) return null + return ( +
+
+ +

تفاصيل الطلب #{booking.id.slice(0,6)}

+
+
+
+
+
+
+

العميل

+

{booking.client?.full_name}

+
+
+ + +
+
+
+
+
+

تفاصيل الحجز #{booking.id.slice(0, 8)}

+ +
+ {booking.is_emergency && ( + + طلب طوارئ عاجل + + )} +
+
+
+
+

الخدمة المطلوبة

+

{booking.service_name}

+
+
+

التكلفة الإجمالية

+

{booking.price} ج.م

+
+
+
+
+ +
+

التاريخ

+

{booking.appointment_date}

+
+
+
+ +
+

الوقت

+

{booking.appointment_time?.slice(0,5)}

+
+
+
+
+
+
+
+ +
+

الموقع

+

{displayAddress}

+
+
+ +
+
+ +
+
+ {(notesText || images.length > 0) && ( +
+

+ + التفاصيل المرفقة +

+ {notesText && ( +

{notesText}

+ )} + {images.length > 0 && ( +
+ {images.map((img, i) => ( + مرفق + ))} +
+ )} +
+ )} +
+

إدارة حالة الطلب

+ {(booking.status === 'confirmed' || booking.status === 'pending') && ( +
+ +