diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ac7dd4f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,101 @@ +# Contributing to Hirfa (حِرفة) + +Thank you for your interest in contributing to **Hirfa**! We welcome contributions from developers of all skill levels. Whether you are fixing a bug, implementing a new feature, or improving documentation, your help is appreciated. + +This document provides a set of guidelines and instructions for contributing to the repository. + +--- + +## 🛠️ Tech Stack Overview +Before you start, please familiarize yourself with the tools we use: +- **Framework:** Next.js 16 (App Router) +- **Language:** TypeScript +- **Styling:** Tailwind CSS v4 +- **Database / Auth:** Supabase (PostgreSQL) +- **Mobile Wrapper:** Capacitor +- **Package Manager:** `pnpm` (highly recommended over `npm` or `yarn`) + +--- + +## 🚀 How to Contribute + +### 1. Setup Your Local Environment +1. Fork the repository and clone your fork: + ```bash + git clone https://github.com/your-username/Hirfa.git + cd Hirfa + ``` +2. Install dependencies using `pnpm`: + ```bash + pnpm install + ``` +3. Set up the `.env.local` file. Copy `.env.example` (if available) or refer to the `README.md` to configure your Supabase keys and SMTP details. +4. Run the development server: + ```bash + pnpm run dev + ``` + +### 2. Find an Issue or Propose a Change +- **Issues:** Look for open issues in the GitHub repository. Issues labeled `good first issue` or `help wanted` are great places to start. +- **Proposals:** If you have an idea for a new feature or architectural change, please open an issue first to discuss it with the maintainers. + +### 3. Make Your Changes +1. Create a new branch for your feature or bugfix: + ```bash + git checkout -b feature/your-feature-name + # or + git checkout -b fix/your-bugfix-name + ``` +2. Make your code changes. +3. Test your changes locally. If your change affects the mobile app UI or behavior, consider running the Capacitor build to verify: + ```bash + pnpm run build:capacitor + npx cap sync android + ``` + +### 4. Code Standards & Best Practices +- **TypeScript:** Use strict typing. Avoid `any` where possible. +- **Components:** Create reusable components inside the `components/` directory. Keep components small, focused, and purely functional if possible. +- **Next.js App Router:** Ensure you understand the difference between Server Components and Client Components (`"use client"`). Be very careful with `generateStaticParams()` as the app heavily relies on Static Exports (`output: 'export'`) for Capacitor builds. +- **Linting:** Run `pnpm run lint` before committing to ensure code style compliance. +- **Arabic First:** Hirfa is built for the Arabic-speaking world. Ensure UI text is properly translated, respects RTL layouts, and uses the correct terminology. + +### 5. Commit Your Changes +We prefer conventional commit messages to keep the history clean and readable: +- `feat:` A new feature +- `fix:` A bug fix +- `docs:` Documentation only changes +- `refactor:` A code change that neither fixes a bug nor adds a feature +- `style:` Changes that do not affect the meaning of the code (white-space, formatting) + +Example: +```bash +git commit -m "feat: add user profile picture uploader" +``` + +### 6. Submit a Pull Request (PR) +1. Push your branch to your fork: + ```bash + git push origin feature/your-feature-name + ``` +2. Open a Pull Request against the `main` branch of the original repository. +3. In your PR description, clearly describe the problem you solved or the feature you added. Link to any relevant issues (e.g., "Closes #12"). +4. The GitHub Actions CI pipeline will automatically run to verify that the project builds the Android APK correctly. Make sure all checks pass! + +--- + +## 📱 Working with the Mobile App (Capacitor) +Since Hirfa is compiled into an APK using Capacitor, changes to routing or static generation can break the mobile build. + +If you add a new dynamic route (e.g., `app/[id]/page.tsx`), you **must** ensure it works with Next.js static exports. +- If it's a Server Component, export `generateStaticParams()`. +- If it's a Client Component, extract the client logic into a separate file (e.g., `ClientPage.tsx`), and let the `page.tsx` be a Server Component that exports `generateStaticParams()` and returns ``. + +*(If you are unsure, open a PR anyway and a maintainer will help you out!)* + +--- + +## 💬 Community & Support +If you get stuck or have questions, feel free to open a Discussion on GitHub or reach out to the core team. + +Thank you for making Hirfa better! ❤️ diff --git a/README.md b/README.md index bdf69a9..b9f35b2 100644 --- a/README.md +++ b/README.md @@ -156,20 +156,37 @@ pnpm run dev 3. Open [http://localhost:3000](http://localhost:3000) in your browser. +### 🏗️ Build Commands + +The project uses Next.js with specialized build commands for different targets: + +- `pnpm run build:web` — Standard Next.js production build for web hosting (Vercel, etc.). +- `pnpm run build:capacitor` — Specialized static export build (`output: 'export'`) that compiles the app for Capacitor, generating static HTML/JS files in the `out` directory. + --- -## 📱 Mobile App (Capacitor) +## 📱 Mobile App (Capacitor) & CI/CD + +Hirfa is built as a mobile-first application and is compiled into a native Android APK using Capacitor. + +### GitHub Actions (Automated CI/CD) -Hirfa is built as a mobile-first application and can be compiled into a native Android app using Capacitor. +The project is equipped with a robust GitHub Actions workflow (`.github/workflows/build-apk.yml`) that automatically builds the Android APK upon pushing to any branch. +1. It builds the Next.js app using `pnpm run build:capacitor`. +2. It safely configures Capacitor and runs `npx cap sync android`. +3. It compiles the APK using Gradle (`assembleDebug`). +4. The resulting `app-debug.apk` is available as a downloadable artifact directly from the Actions tab on GitHub. -### Build the Android App +### Manual Local Build -1. Build the Next.js web application: +If you want to build the APK locally on your machine: + +1. Build the web assets specifically for Capacitor: ```bash -pnpm run build +pnpm run build:capacitor ``` -2. Sync the web assets with the Capacitor Android project: +2. Sync the web assets with the Android project: ```bash npx cap sync android ``` @@ -178,7 +195,12 @@ npx cap sync android ```bash npx cap open android ``` -Alternatively, you can use the provided `./build_apk.sh` script to automate the APK generation process on Linux environments. + +--- + +## 🤝 Contributing + +We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for detailed instructions on how to set up your environment, follow our coding standards, and submit pull requests. --- diff --git a/app.jpg b/app.jpg deleted file mode 100644 index b01fd15..0000000 Binary files a/app.jpg and /dev/null differ diff --git a/app/(main)/worker/home/page.tsx b/app/(main)/worker/home/page.tsx index 156ad0f..0087812 100644 --- a/app/(main)/worker/home/page.tsx +++ b/app/(main)/worker/home/page.tsx @@ -9,7 +9,7 @@ const Empty = ({ I, t }: any) =>

{t}

export default function CraftsmanHome() { - const { profile: p, newRequests: reqs, appointments: apps, isAvailable: isAv, activeEmergency: actEm, acceptEmergency: accEm, toggleAvailability: tAv, handleRequest: hReq, handleLogout: hLog } = useHome() + const { profile: p, newRequests: reqs, appointments: apps, isAvailable: isAv, activeEmergency: actEm, acceptEmergency: accEm, toggleAvailability: tAv, handleRequest: hReq, handleLogout: hLog, workerSub } = useHome() const stats = [{ l: 'طلبات اليوم', v: p?.completed_orders || 0, i: ClipboardCheck, c: '#FF8A00' }, { l: 'الأرباح', v: p?.total_earnings || 0, i: Banknote, c: '#FFB800' }, { l: 'التقييم', v: p?.rating || 0, i: Star, c: '#FFB800' }] const links = [{ l: 'الجدول', h: '/worker/schedule', i: Calendar }, { l: 'الرسائل', h: '/worker/messages', i: MessageSquare }, { l: 'المحفظة', h: '/worker/wallet', i: Wallet }, { l: 'المعرض', h: '/worker/profile/gallery', i: LayoutGrid }] @@ -29,12 +29,22 @@ export default function CraftsmanHome() {

المطلوب: {actEm.description}

العميل: {actEm.client?.full_name || actEm.client?.email || 'عميل'}

- + {(workerSub === 'master' || workerSub === 'premium') ? ( + + ) : ( + + + للأسف، استقبال الطوارئ متاح لباقات ماستر وبريميوم فقط. رقي باقتك الآن! + + )} )} diff --git a/app/(main)/worker/profile/page.tsx b/app/(main)/worker/profile/page.tsx index 9cc8126..958596e 100644 --- a/app/(main)/worker/profile/page.tsx +++ b/app/(main)/worker/profile/page.tsx @@ -1,6 +1,6 @@ 'use client' -import React from 'react' +import React, { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { LogOut, Star, Pencil, Fingerprint, LayoutGrid, Calendar, Wallet, CreditCard, @@ -14,11 +14,22 @@ import { StatCard } from '@/components/ui/profile/StatCard' import { MenuGroup } from '@/components/ui/profile/MenuGroup' import { MenuLink } from '@/components/ui/profile/MenuLink' import { ProfileAvatarInfo } from '@/components/ui/profile/ProfileAvatarInfo' +import { getActiveWorkerSubscription, WorkerSubscription } from '@/lib/supabase/worker-subscriptions' export default function ProfilePage() { const { profile } = useAuth() const supabase = createClient() const router = useRouter() + const [subscription, setSubscription] = useState(null) + + useEffect(() => { + if (!profile?.id) return + const fetchSub = async () => { + const sub = await getActiveWorkerSubscription(profile.id) + setSubscription(sub) + } + fetchSub() + }, [profile]) const handleLogout = async () => { await supabase.auth.signOut() @@ -46,7 +57,8 @@ export default function ProfilePage() { title: 'الشؤون المالية', items: [ { title: 'الأرباح والمحفظة', href: '/worker/wallet', icon: Wallet, color: '#FFB800', bg: '#1A1813' }, - { title: 'طرق الدفع', href: '/worker/profile/payment-methods', icon: CreditCard, color: '#FFB800', bg: '#1A1813' } + { title: 'طرق الدفع', href: '/worker/profile/payment-methods', icon: CreditCard, color: '#FFB800', bg: '#1A1813' }, + { title: 'باقة الاشتراك', href: '/worker/subscriptions', icon: Star, color: '#3B82F6', bg: '#172554' } ] }, { @@ -85,6 +97,21 @@ export default function ProfilePage() { /> + {subscription?.plan_type === 'premium' && ( +
+ +
+
+

خدمة الدعم المتميزة

+

تواصل مع مدير حسابك المخصص

+
+
+ +
+
+
+ )} +
{menuGroups.map((group, i) => ( diff --git a/app/(main)/worker/subscriptions/page.tsx b/app/(main)/worker/subscriptions/page.tsx new file mode 100644 index 0000000..5675ec0 --- /dev/null +++ b/app/(main)/worker/subscriptions/page.tsx @@ -0,0 +1,215 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { SubPageLayout } from '@/components/ui/SubPageLayout'; +import { PageHeader } from '@/components/ui/PageHeader'; +import WorkerPricingCard from '@/components/subscriptions/WorkerPricingCard'; +import { getActiveWorkerSubscription, subscribeWorkerToPlan, WorkerPlanType, WorkerSubscription } from '@/lib/supabase/worker-subscriptions'; +import { useAuth } from '@/contexts/AuthContext'; + +export default function WorkerSubscriptionsPage() { + const { profile } = useAuth(); + const [activeSubscription, setActiveSubscription] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [confirmPlan, setConfirmPlan] = useState(null); + + useEffect(() => { + if (profile) { + loadSubscription(); + } + }, [profile]); + + const loadSubscription = async () => { + if (!profile) return; + setIsLoading(true); + const sub = await getActiveWorkerSubscription(profile.id); + setActiveSubscription(sub); + setIsLoading(false); + }; + + const handleSubscribeClick = (plan: WorkerPlanType) => { + setConfirmPlan(plan); + }; + + const executeSubscription = async () => { + if (!profile || !confirmPlan) return; + + setActionLoading(confirmPlan); + const planToSubscribe = confirmPlan; + setConfirmPlan(null); // Close modal + + const result = await subscribeWorkerToPlan(profile.id, planToSubscribe); + setActionLoading(null); + + if (result.success) { + alert('تم الاشتراك بنجاح!'); + await loadSubscription(); // Refresh + } else { + alert(result.error || 'حدث خطأ أثناء الاشتراك'); + } + }; + + const plans = [ + { + type: 'basic' as WorkerPlanType, + title: 'الأساسية', + price: 0, + features: [ + 'عمولة المنصة 15%', + 'الظهور العادي في نتائج البحث', + 'استقبال الطلبات العادية', + 'دعم فني عبر البريد الإلكتروني', + ], + }, + { + type: 'pro' as WorkerPlanType, + title: 'حرفة برو', + price: 99, + features: [ + 'تخفيض العمولة إلى 10%', + 'شعار "حرفي معتمد" على الملف الشخصي', + 'الظهور المتقدم في نتائج البحث', + 'رؤية تقييمات وتاريخ العميل قبل القبول', + ], + }, + { + type: 'master' as WorkerPlanType, + title: 'حرفة ماستر', + price: 199, + isPopular: true, + features: [ + 'تخفيض العمولة إلى 5%', + 'شعار "حرفي خبير"', + 'أولوية استقبال طلبات الطوارئ', + 'رؤية العنوان التفصيلي قبل القبول', + ], + }, + { + type: 'premium' as WorkerPlanType, + title: 'حرفة بريميوم', + price: 399, + features: [ + 'بدون عمولة (0%) من المنصة', + 'استقبال طلبات الطوارئ وتمييز بريميوم', + 'مدير حساب شخصي مخصص (VIP)', + 'رؤية العنوان التفصيلي قبل القبول', + ], + }, + ]; + + return ( + + +
+ {/* Background Gradients customized for workers */} +
+ +
+

+ استثمر في مهنتك وزد أرباحك +

+

+ باقات حرفة للحرفيين تمنحك مزايا استثنائية تشمل تخفيض العمولات، أولوية في الظهور، وزيادة في عدد الطلبات. +

+
+ + {isLoading ? ( +
+
+
+ ) : ( +
+ {plans.map((plan) => ( + + ))} +
+ )} + +
+
+ + + + + يمكنك تغيير أو إلغاء اشتراكك في أي وقت بسهولة ومن خلال محفظتك. +
+
+
+ + {/* Payment Confirmation Modal */} + {confirmPlan && ( +
+
+

تأكيد الاشتراك والدفع

+ + {(() => { + const selectedPlan = plans.find(p => p.type === confirmPlan); + const isFree = selectedPlan?.price === 0; + const endDate = new Date(); + endDate.setDate(endDate.getDate() + 30); + const formatter = new Intl.DateTimeFormat('ar-EG', { year: 'numeric', month: 'long', day: 'numeric' }); + + return ( +
+
+
+ الباقة المختارة: + {selectedPlan?.title} +
+ {!isFree && ( +
+ فترة الاشتراك: + حتى {formatter.format(endDate)} +
+ )} +
+
+ المبلغ المطلوب: + {isFree ? 'مجانًا' : `${selectedPlan?.price} ج.م`} +
+
+ + {!isFree ? ( +
+ سيتم خصم المبلغ من أرباح محفظتك أو البطاقة المحفوظة. +
+ ) : ( +
+ العودة للباقة الأساسية الافتراضية +
+ )} + +
+ + +
+
+ ); + })()} +
+
+ )} + + ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index dc82570..fcb954f 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { useAuth } from '@/contexts/AuthContext' -type TabType = 'overview' | 'users' | 'activations' | 'bookings' | 'emergencies' | 'categories' | 'rules' | 'messages' | 'services' | 'reviews' | 'transactions' | 'gallery' | 'notifications' +type TabType = 'overview' | 'users' | 'activations' | 'bookings' | 'emergencies' | 'categories' | 'rules' | 'messages' | 'services' | 'reviews' | 'transactions' | 'gallery' | 'notifications' | 'subscriptions' export default function AdminDashboard() { const router = useRouter() const supabase = createClient() @@ -32,6 +32,8 @@ export default function AdminDashboard() { const [transactionsList, setTransactionsList] = useState([]) const [galleryList, setGalleryList] = useState([]) const [notificationsList, setNotificationsList] = useState([]) + const [clientSubscriptions, setClientSubscriptions] = useState([]) + const [workerSubscriptions, setWorkerSubscriptions] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [roleFilter, setRoleFilter] = useState<'all' | 'client' | 'worker' | 'admin'>('all') const [newCatAr, setNewCatAr] = useState('') @@ -111,6 +113,14 @@ export default function AdminDashboard() { .from('notifications') .select('*, user:user_id(full_name)') .order('created_at', { ascending: false }) + const { data: clientSubs } = await supabase + .from('client_subscriptions') + .select('*, user:user_id(full_name, phone)') + .order('created_at', { ascending: false }) + const { data: workerSubs } = await supabase + .from('worker_subscriptions') + .select('*, worker:worker_id(full_name, phone)') + .order('created_at', { ascending: false }) setProfilesList(profiles || []) setBookingsList(bookings || []) setEmergenciesList(emergencies || []) @@ -121,6 +131,8 @@ export default function AdminDashboard() { setTransactionsList(transactions || []) setGalleryList(gallery || []) setNotificationsList(notifications || []) + setClientSubscriptions(clientSubs || []) + setWorkerSubscriptions(workerSubs || []) const clients = profiles?.filter(p => p.role === 'client') || [] const workers = profiles?.filter(p => p.role === 'worker') || [] const activeE = emergencies?.filter(e => e.status !== 'completed') || [] @@ -329,7 +341,8 @@ export default function AdminDashboard() { { id: 'gallery' as TabType, label: 'معرض الأعمال', icon: MapPin }, { id: 'notifications' as TabType, label: 'الإشعارات', icon: Send }, { id: 'rules' as TabType, label: 'صلاحيات الأدمن', icon: ShieldCheck }, - { id: 'messages' as TabType, label: 'إرسال رسائل', icon: MessageSquare } + { id: 'messages' as TabType, label: 'إرسال رسائل', icon: MessageSquare }, + { id: 'subscriptions' as TabType, label: 'الاشتراكات', icon: Award } ] return (
@@ -1152,8 +1165,18 @@ export default function AdminDashboard() {
)} + + {activeTab === 'subscriptions' && ( +
+
+
+
إجمالي الاشتراكات (العملاء)
+
{clientSubscriptions.length}
+
+
+
إجمالي الاشتراكات (الحرفيين)
+
{workerSubscriptions.length}
+
+
+
الاشتراكات النشطة
+
{clientSubscriptions.filter(s => s.status === 'active').length + workerSubscriptions.filter(s => s.status === 'active').length}
+
+
+ +
+

اشتراكات العملاء

+
+ + + + + + + + + + + + + {clientSubscriptions.map((sub, i) => ( + + + + + + + + + ))} + {clientSubscriptions.length === 0 && ( + + )} + +
العميلالهاتفالباقةالحالةتاريخ البدءتاريخ الانتهاء
{sub.user?.full_name || 'بدون اسم'}{sub.user?.phone || 'بدون رقم'} + {sub.plan_type} + + {sub.status} + {new Date(sub.start_date).toLocaleDateString('ar-EG')}{sub.end_date ? new Date(sub.end_date).toLocaleDateString('ar-EG') : 'غير محدد'}
لا توجد اشتراكات للعملاء
+
+
+ +
+

اشتراكات الحرفيين

+
+ + + + + + + + + + + + + {workerSubscriptions.map((sub, i) => ( + + + + + + + + + ))} + {workerSubscriptions.length === 0 && ( + + )} + +
الحرفيالهاتفالباقةالحالةتاريخ البدءتاريخ الانتهاء
{sub.worker?.full_name || 'بدون اسم'}{sub.worker?.phone || 'بدون رقم'} + {sub.plan_type} + + {sub.status} + {new Date(sub.start_date).toLocaleDateString('ar-EG')}{sub.end_date ? new Date(sub.end_date).toLocaleDateString('ar-EG') : 'غير محدد'}
لا توجد اشتراكات للحرفيين
+
+
+
+ )} )} diff --git a/app/api/admin/delete-notification/route.ts b/app/api/admin/delete-notification/route.ts new file mode 100644 index 0000000..8a8c736 --- /dev/null +++ b/app/api/admin/delete-notification/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server' +import { createServerClient as createServerSupabase } from '@supabase/ssr' +import { createClient as createAdminClient } from '@supabase/supabase-js' +import { cookies } from 'next/headers' + +export async function POST(req: Request) { + try { + const { id } = await req.json() + + if (!id) { + return NextResponse.json({ error: 'Notification ID is required' }, { status: 400 }) + } + + const cookieStore = await cookies() + const supabase = createServerSupabase( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ) + }, + }, + } + ) + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { data: profile } = await supabase + .from('profiles') + .select('role') + .eq('id', user.id) + .single() + + if (profile?.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden: Admins only' }, { status: 403 }) + } + + const supabaseAdmin = createAdminClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ) + + const { error: deleteError } = await supabaseAdmin.from('notifications').delete().eq('id', id) + + if (deleteError) { + console.error('Delete Notification Error:', deleteError) + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Delete Notification Route Error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ) + } +} diff --git a/app/client/home/page.tsx b/app/client/home/page.tsx index 0d6faa8..4603743 100644 --- a/app/client/home/page.tsx +++ b/app/client/home/page.tsx @@ -195,9 +195,16 @@ export default function ClientHomePage() {

{worker.full_name}

-

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

-

{worker.governorate ? `${worker.governorate} - ${worker.area}` : 'موقع غير محدد'}

-

تم إنجاز {worker.completed_orders || 0} مهمة

+

+ {worker.profession || 'حرفي'} + {worker.workerPlan && worker.workerPlan !== 'basic' && ( + + {worker.workerPlan === 'premium' ? 'بريميوم' : worker.workerPlan === 'master' ? 'حرفي خبير' : 'حرفي معتمد'} + + )} +

+

{worker.governorate ? `${worker.governorate} - ${worker.area}` : 'موقع غير محدد'}

+

تم إنجاز {worker.completed_orders || 0} مهمة

diff --git a/app/client/order/invoice/page.tsx b/app/client/order/invoice/page.tsx index b55cb35..cad05fa 100644 --- a/app/client/order/invoice/page.tsx +++ b/app/client/order/invoice/page.tsx @@ -26,6 +26,7 @@ function InvoiceContent() { const [booking, setBooking] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) + const [clientSub, setClientSub] = useState('free') useEffect(() => { if (!bookingId) { setLoading(false); setError(true); return } @@ -36,7 +37,22 @@ function InvoiceContent() { .select('*, worker:worker_id(id, full_name, avatar_url, profession, rating)') .eq('id', bookingId) .single() - if (data) setBooking(data as unknown as BookingData) + if (data) { + setBooking(data as unknown as BookingData) + + // Fetch client subscription to check for benefits + const { data: subData } = await supabase + .from('client_subscriptions') + .select('plan_type') + .eq('user_id', data.client_id) + .eq('status', 'active') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + if (subData) { + setClientSub(subData.plan_type) + } + } else setError(true) setLoading(false) } @@ -55,8 +71,18 @@ function InvoiceContent() { } const worker = booking?.worker - const inspectionFee = booking ? Math.round(booking.price * 0.3) : 0 - const laborFee = booking ? booking.price - inspectionFee : 0 + + // Waive inspection fee for Shield and Estate plans + const isFeeWaived = clientSub === 'shield' || clientSub === 'estate' + const baseInspectionFee = booking ? Math.round(booking.price * 0.3) : 0 + const inspectionFee = isFeeWaived ? 0 : baseInspectionFee + const laborFee = booking ? booking.price - baseInspectionFee : 0 + const subTotal = laborFee + inspectionFee + + // 10% discount on total for Care, Shield, and Estate + const hasDiscount = clientSub === 'care' || clientSub === 'shield' || clientSub === 'estate' + const discountAmount = hasDiscount ? Math.round(subTotal * 0.10) : 0 + const totalPrice = subTotal - discountAmount return (
@@ -100,8 +126,13 @@ function InvoiceContent() {
- {formatCurrency(inspectionFee)} - سعر المعاينة + + {formatCurrency(baseInspectionFee)} + + + سعر المعاينة + {isFeeWaived && مجانًا بباقة {clientSub === 'shield' ? 'شيلد' : 'إستيت'}} +
{formatCurrency(laborFee)} @@ -111,8 +142,18 @@ function InvoiceContent() {
+ {hasDiscount && ( +
+ -{formatCurrency(discountAmount)} + + خصم 10% + ميزة باقة {clientSub === 'care' ? 'كير' : clientSub === 'shield' ? 'شيلد' : 'إستيت'} + +
+ )} +
- {formatCurrency(booking?.price || 0)} + {formatCurrency(totalPrice)} الإجمالي
diff --git a/app/client/orders/page.tsx b/app/client/orders/page.tsx index cea4c7c..e685fa1 100644 --- a/app/client/orders/page.tsx +++ b/app/client/orders/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { createClient } from '@/lib/supabase/client' +import { getActiveSubscription } from '@/lib/supabase/subscriptions' interface Booking { id: string @@ -49,6 +50,7 @@ export default function OrdersPage() { const [bookings, setBookings] = useState([]) const [loading, setLoading] = useState(true) const [activeTab, setActiveTab] = useState('all') + const [clientPlan, setClientPlan] = useState(null) useEffect(() => { const fetchBookings = async () => { @@ -56,6 +58,9 @@ export default function OrdersPage() { const { data: { user } } = await supabase.auth.getUser() if (!user) return + const sub = await getActiveSubscription(user.id) + if (sub) setClientPlan(sub.plan_type) + const { data } = await supabase .from('bookings') .select('*, worker:worker_id(full_name, avatar_url, profession)') @@ -190,8 +195,21 @@ export default function OrdersPage() { )}
- {/* View Details */} -
+ {/* View Details / Warranty */} +
+ {clientPlan === 'estate' && (booking.status === 'completed' || booking.status === 'closed') ? ( + + ) : ( +
+ )} عرض التفاصيل diff --git a/app/client/profile/page.tsx b/app/client/profile/page.tsx index 1d2132a..1e6b477 100644 --- a/app/client/profile/page.tsx +++ b/app/client/profile/page.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import Image from 'next/image' import { - LogOut, Pencil, Home, Wallet, BellRing, HelpCircle, FileText, ShoppingBag + LogOut, Pencil, Home, Wallet, BellRing, HelpCircle, FileText, ShoppingBag, Star } from 'lucide-react' import { useAuth } from '@/contexts/AuthContext' import { createClient } from '@/lib/supabase/client' @@ -13,6 +13,7 @@ import { SubPageLayout } from '@/components/ui/SubPageLayout' import { StatCard } from '@/components/ui/profile/StatCard' import { MenuGroup } from '@/components/ui/profile/MenuGroup' import { MenuLink } from '@/components/ui/profile/MenuLink' +import { getActiveSubscription, ClientSubscription } from '@/lib/supabase/subscriptions' export default function ProfilePage() { const { profile } = useAuth() @@ -20,6 +21,7 @@ export default function ProfilePage() { const router = useRouter() const [orderCount, setOrderCount] = useState(0) const [walletBalance, setWalletBalance] = useState(0) + const [subscription, setSubscription] = useState(null) useEffect(() => { if (!profile?.id) return @@ -34,6 +36,9 @@ export default function ProfilePage() { if (profile.wallet_balance != null) { setWalletBalance(profile.wallet_balance) } + + const sub = await getActiveSubscription(profile.id) + setSubscription(sub) } fetchData() }, [profile, supabase]) @@ -50,6 +55,7 @@ export default function ProfilePage() { { title: 'تعديل الملف الشخصي', href: '/client/profile/edit', icon: Pencil, color: '#FF8A00', bg: '#1E1B15' }, { title: 'العناوين المحفوظة', href: '/client/addresses', icon: Home, color: '#FFB800', bg: '#1A1813' }, { title: 'طلباتي', href: '/client/orders', icon: ShoppingBag, color: '#FF8A00', bg: '#1E1B15' }, + { title: 'اشتراكاتي', href: '/client/subscriptions', icon: Star, color: '#FFB800', bg: '#1A1813' }, ] }, { @@ -112,6 +118,21 @@ export default function ProfilePage() { />
+ {subscription?.plan_type === 'estate' && ( +
+ + + )} +
{menuGroups.map((group, i) => ( diff --git a/app/client/subscriptions/page.tsx b/app/client/subscriptions/page.tsx new file mode 100644 index 0000000..fa07c54 --- /dev/null +++ b/app/client/subscriptions/page.tsx @@ -0,0 +1,211 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { SubPageLayout } from '@/components/ui/SubPageLayout'; +import { PageHeader } from '@/components/ui/PageHeader'; +import PricingCard from '@/components/subscriptions/PricingCard'; +import { getActiveSubscription, subscribeToPlan, PlanType, ClientSubscription } from '@/lib/supabase/subscriptions'; +import { useAuth } from '@/contexts/AuthContext'; + +export default function SubscriptionsPage() { + const { profile } = useAuth(); + const [activeSubscription, setActiveSubscription] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [confirmPlan, setConfirmPlan] = useState(null); + + useEffect(() => { + if (profile) { + loadSubscription(); + } + }, [profile]); + + const loadSubscription = async () => { + if (!profile) return; + setIsLoading(true); + const sub = await getActiveSubscription(profile.id); + setActiveSubscription(sub); + setIsLoading(false); + }; + + const handleSubscribeClick = (plan: PlanType) => { + setConfirmPlan(plan); + }; + + const executeSubscription = async () => { + if (!profile || !confirmPlan) return; + + setActionLoading(confirmPlan); + const planToSubscribe = confirmPlan; + setConfirmPlan(null); // Close modal + + const result = await subscribeToPlan(profile.id, planToSubscribe); + setActionLoading(null); + + if (result.success) { + alert('تم الاشتراك بنجاح! شكراً لك.'); + await loadSubscription(); // Refresh + } else { + alert(result.error || 'حدث خطأ أثناء الاشتراك'); + } + }; + + const plans = [ + { + type: 'free' as PlanType, + title: 'الباقة المجانية', + price: 0, + features: [ + 'تسجيل الدخول والتصفح مجاناً', + 'حجز خدمات الصيانة العادية', + 'دعم فني عبر البريد الإلكتروني', + ], + }, + { + type: 'care' as PlanType, + title: 'حرفة كير', + price: 49, + features: [ + 'كل ميزات الباقة المجانية', + 'خصم 10% على إجمالي الفاتورة', + ], + }, + { + type: 'shield' as PlanType, + title: 'حرفة شيلد', + price: 99, + isPopular: true, + features: [ + 'خصم 10% على إجمالي الفاتورة', + 'بدون رسوم معاينة', + 'طلبات مميزة (أولوية قصوى للمندوب)', + ], + }, + { + type: 'estate' as PlanType, + title: 'حرفة إستيت', + price: 299, + features: [ + 'بدون رسوم معاينة', + 'طلبات مميزة (أولوية قصوى)', + 'مدير حساب شخصي مخصص (VIP)', + 'طلب ضمان للخدمات المكتملة', + ], + }, + ]; + + return ( + + +
+ {/* Background Gradients */} +
+ +
+

+ اختر الباقة المناسبة لاحتياجاتك +

+

+ وفر أكثر مع باقات حرفة الشهرية والسنوية. استمتع بخصومات حصرية، فحوصات دورية، وأولوية في الخدمة. +

+
+ + {isLoading ? ( +
+
+
+ ) : ( +
+ {plans.map((plan) => ( + + ))} +
+ )} + +
+
+ + + + + يمكنك ترقية أو إلغاء اشتراكك في أي وقت بسهولة وبدون رسوم خفية. +
+
+
+ + {/* Payment Confirmation Modal */} + {confirmPlan && ( +
+
+

تأكيد الاشتراك والدفع

+ + {(() => { + const selectedPlan = plans.find(p => p.type === confirmPlan); + const isFree = selectedPlan?.price === 0; + const endDate = new Date(); + endDate.setDate(endDate.getDate() + 30); + const formatter = new Intl.DateTimeFormat('ar-EG', { year: 'numeric', month: 'long', day: 'numeric' }); + + return ( +
+
+
+ الباقة المختارة: + {selectedPlan?.title} +
+ {!isFree && ( +
+ فترة الاشتراك: + حتى {formatter.format(endDate)} +
+ )} +
+
+ المبلغ المطلوب: + {isFree ? 'مجانًا' : `${selectedPlan?.price} ج.م`} +
+
+ + {!isFree ? ( +
+ سيتم خصم المبلغ من المحفظة أو البطاقة المحفوظة. +
+ ) : ( +
+ العودة للباقة المجانية الافتراضية +
+ )} + +
+ + +
+
+ ); + })()} +
+
+ )} + + ); +} diff --git a/capacitor.config.ts b/capacitor.config.ts index 6c24e8e..f3a76ef 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -5,7 +5,7 @@ const config: CapacitorConfig = { appName: 'Hirfa', webDir: 'out', server: { - url: 'https://hirfa-amber.vercel.app', + url: 'https://hirfa-five.vercel.app/', cleartext: true } }; diff --git a/components/shared/CraftsmanCard.tsx b/components/shared/CraftsmanCard.tsx index 2b0f4a7..04875fd 100644 --- a/components/shared/CraftsmanCard.tsx +++ b/components/shared/CraftsmanCard.tsx @@ -6,6 +6,7 @@ import Link from 'next/link' interface CraftsmanCardProps { craftsman: Craftsman variant?: 'compact' | 'full' + workerPlan?: 'basic' | 'pro' | 'master' | 'premium' } const tierColors = { @@ -20,9 +21,27 @@ const tierLabels = { skilled: 'ماهر', pro: 'احترافي', master: 'خبير', + master: 'خبير', } -export function CraftsmanCard({ craftsman, variant = 'full' }: CraftsmanCardProps) { +const planLabels = { + basic: 'أساسي', + pro: 'حرفي معتمد', + master: 'حرفي خبير', + premium: 'بريميوم' +} + +const planColors = { + basic: 'text-primary bg-primary/10', + pro: 'text-blue-600 bg-blue-100 border border-blue-200', + master: 'text-amber-600 bg-amber-100 border border-amber-200', + premium: 'text-white bg-gradient-to-r from-indigo-500 to-purple-600 shadow-sm' +} + +export function CraftsmanCard({ craftsman, variant = 'full', workerPlan }: CraftsmanCardProps) { + const displayLabel = workerPlan ? planLabels[workerPlan] : tierLabels[craftsman.tier] + const displayStyle = workerPlan ? planColors[workerPlan] : 'text-primary bg-primary/10' + if (variant === 'compact') { return ( @@ -68,8 +87,8 @@ export function CraftsmanCard({ craftsman, variant = 'full' }: CraftsmanCardProp {craftsman.distance} كم
-
- {tierLabels[craftsman.tier]} +
+ {displayLabel}
@@ -99,6 +118,11 @@ export function CraftsmanCard({ craftsman, variant = 'full' }: CraftsmanCardProp {craftsman.verified && ( )} + {workerPlan && workerPlan !== 'basic' && ( + + {displayLabel} + + )}

{craftsman.specialtyAr} diff --git a/components/subscriptions/PricingCard.tsx b/components/subscriptions/PricingCard.tsx new file mode 100644 index 0000000..62188b0 --- /dev/null +++ b/components/subscriptions/PricingCard.tsx @@ -0,0 +1,82 @@ +'use client'; + +import React from 'react'; +import { PlanType } from '@/lib/supabase/subscriptions'; +import { Check } from 'lucide-react'; + +interface PricingCardProps { + title: string; + price: number; + type: PlanType; + features: string[]; + isPopular?: boolean; + isActive?: boolean; + onSubscribe: (plan: PlanType) => void; + isLoading?: boolean; +} + +export default function PricingCard({ + title, + price, + type, + features, + isPopular, + isActive, + onSubscribe, + isLoading +}: PricingCardProps) { + return ( +

+ {isPopular && ( +
+ الأكثر طلباً +
+ )} + {isActive && ( +
+ باقتك الحالية +
+ )} + + {/* Decorative gradient blur */} +
+ +
+

{title}

+
+ + {price === 0 ? 'مجانًا' : price} + + {price > 0 && ج.م / شهرياً} +
+
+ +
    + {features.map((feature, idx) => ( +
  • +
    + +
    + {feature} +
  • + ))} +
+ + +
+ ); +} diff --git a/components/subscriptions/WorkerPricingCard.tsx b/components/subscriptions/WorkerPricingCard.tsx new file mode 100644 index 0000000..43915e1 --- /dev/null +++ b/components/subscriptions/WorkerPricingCard.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React from 'react'; +import { WorkerPlanType } from '@/lib/supabase/worker-subscriptions'; +import { Check } from 'lucide-react'; + +interface WorkerPricingCardProps { + title: string; + price: number; + type: WorkerPlanType; + features: string[]; + isPopular?: boolean; + isActive?: boolean; + onSubscribe: (plan: WorkerPlanType) => void; + isLoading?: boolean; +} + +export default function WorkerPricingCard({ + title, + price, + type, + features, + isPopular, + isActive, + onSubscribe, + isLoading +}: WorkerPricingCardProps) { + // Use blue/indigo accent for the worker side, distinguishing it slightly from the client side + const accentColor = isPopular ? 'from-indigo-500 to-blue-600' : 'from-blue-400 to-indigo-500'; + const shadowColor = isPopular ? 'shadow-blue-500/30' : ''; + const activeBorder = 'border-blue-500 shadow-[0_0_40px_-10px_rgba(59,130,246,0.3)]'; + + return ( +
+ {isPopular && ( +
+ الأكثر طلباً +
+ )} + {isActive && ( +
+ باقتك الحالية +
+ )} + + {/* Decorative gradient blur */} +
+ +
+

{title}

+
+ + {price === 0 ? 'مجانًا' : price} + + {price > 0 && ج.م / شهرياً} +
+
+ +
    + {features.map((feature, idx) => ( +
  • +
    + +
    + {feature} +
  • + ))} +
+ + +
+ ); +} diff --git a/components/ui/orders/OrderCard.tsx b/components/ui/orders/OrderCard.tsx index 6184952..1f70564 100644 --- a/components/ui/orders/OrderCard.tsx +++ b/components/ui/orders/OrderCard.tsx @@ -1,6 +1,9 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import Image from 'next/image' -import { ClipboardCheck, Clock, MapPin, Banknote, Navigation, Save } from 'lucide-react' +import { ClipboardCheck, Clock, MapPin, Banknote, Navigation, Save, ShieldAlert, Star } from 'lucide-react' +import { createClient } from '@/lib/supabase/client' +import { getActiveSubscription } from '@/lib/supabase/subscriptions' +import { getActiveWorkerSubscription } from '@/lib/supabase/worker-subscriptions' type TabType = 'pending' | 'confirmed' | 'completed' @@ -18,6 +21,24 @@ interface OrderCardProps { } export function OrderCard({ order, activeTab, onUpdateStatus, onUpdateTracking }: OrderCardProps) { + const supabase = createClient() + const [clientPlan, setClientPlan] = useState(null) + const [workerPlan, setWorkerPlan] = useState(null) + + useEffect(() => { + const fetchSubs = async () => { + if (order.client_id) { + const cSub = await getActiveSubscription(order.client_id) + if (cSub) setClientPlan(cSub.plan_type) + } + if (order.worker_id) { + const wSub = await getActiveWorkerSubscription(order.worker_id) + if (wSub) setWorkerPlan(wSub.plan_type) + } + } + fetchSubs() + }, [order.client_id, order.worker_id]) + const tStatus = order.tracking_status || 'accepted' let displayAddress = order.address || '' @@ -33,6 +54,22 @@ export function OrderCard({ order, activeTab, onUpdateStatus, onUpdateTracking } lng = coords[1] } } + + const canViewFullAddress = workerPlan === 'master' || workerPlan === 'premium' + const canViewClientRating = workerPlan !== 'basic' + + if (!canViewFullAddress && activeTab === 'pending') { + // Only show governorate/area or a generic message for non-premium before accepting + const addressParts = displayAddress.split('،') + if (addressParts.length > 2) { + displayAddress = `${addressParts[0]}، ${addressParts[1]} (التفاصيل تظهر للمشتركين أو بعد القبول)` + } else { + displayAddress = 'العنوان التفصيلي يظهر للمشتركين أو بعد القبول' + } + // Hide coordinates so they can't open map + lat = null + lng = null + } const [etaInput, setEtaInput] = useState(order.eta || '') const [notesInput, setNotesInput] = useState(order.status_notes || '') @@ -69,8 +106,13 @@ export function OrderCard({ order, activeTab, onUpdateStatus, onUpdateTracking }
{order.is_emergency ? ( -
طوارئ
+
طوارئ
) : null} + {clientPlan && ['shield', 'estate'].includes(clientPlan) && ( +
+ أولوية قصوى +
+ )}
- {order.client?.full_name || 'عميل'} + + {order.client?.full_name || 'عميل'} + {canViewClientRating && activeTab === 'pending' && ( + تقييم: 4.8 + )} + {order.service_name} diff --git a/fix_dynamic_routes.py b/fix_dynamic_routes.py deleted file mode 100644 index 6464c36..0000000 --- a/fix_dynamic_routes.py +++ /dev/null @@ -1,32 +0,0 @@ -import glob -import os - -routes = [ - "app/client/rate-review/[bookingId]/page.tsx", - "app/client/addresses/edit/[id]/page.tsx", - "app/client/order/cancel/[id]/page.tsx", - "app/client/craftsman/[id]/page.tsx", - "app/client/services/[categoryId]/page.tsx", - "app/client/booking/[workerId]/page.tsx", - "app/client/chat/[id]/page.tsx", - "app/client/wallet/edit-card/[id]/page.tsx", - "app/(main)/worker/booking/[id]/page.tsx", - "app/(main)/worker/messages/[id]/page.tsx", - "app/(onboarding)/intro/[step]/page.tsx" -] - -code_to_add = "\n\nexport function generateStaticParams() {\n return [];\n}\n" - -for route in routes: - if os.path.exists(route): - with open(route, 'r') as f: - content = f.read() - if 'generateStaticParams' not in content: - with open(route, 'a') as f: - f.write(code_to_add) - print(f"Fixed {route}") - else: - print(f"Skipped {route} (already has generateStaticParams)") - else: - print(f"Not found: {route}") - diff --git a/fix_dynamic_routes_v2.py b/fix_dynamic_routes_v2.py deleted file mode 100644 index 4c625d4..0000000 --- a/fix_dynamic_routes_v2.py +++ /dev/null @@ -1,64 +0,0 @@ -import glob -import os -import re - -routes = [ - "app/client/rate-review/[bookingId]", - "app/client/addresses/edit/[id]", - "app/client/order/cancel/[id]", - "app/client/craftsman/[id]", - "app/client/services/[categoryId]", - "app/client/booking/[workerId]", - "app/client/chat/[id]", - "app/client/wallet/edit-card/[id]", - "app/(main)/worker/booking/[id]", - "app/(main)/worker/messages/[id]", - "app/(onboarding)/intro/[step]" -] - -for route in routes: - page_path = os.path.join(route, "page.tsx") - layout_path = os.path.join(route, "layout.tsx") - - if os.path.exists(page_path): - with open(page_path, 'r') as f: - content = f.read() - - # Remove the generated block - # We look for export function generateStaticParams - pattern = r"\n\nexport function generateStaticParams\(\) \{.*?\n\}\n*" - new_content = re.sub(pattern, "", content, flags=re.DOTALL) - - if content != new_content: - with open(page_path, 'w') as f: - f.write(new_content) - print(f"Removed generateStaticParams from {page_path}") - - # Create layout.tsx - if route == "app/(onboarding)/intro/[step]": - layout_content = """export function generateStaticParams() { - return [ - { step: '1' }, - { step: '2' }, - { step: '3' }, - { step: '4' } - ]; -} - -export default function Layout({ children }: { children: React.ReactNode }) { - return children; -} -""" - else: - layout_content = """export function generateStaticParams() { - return []; -} - -export default function Layout({ children }: { children: React.ReactNode }) { - return children; -} -""" - with open(layout_path, 'w') as f: - f.write(layout_content) - print(f"Created {layout_path}") - diff --git a/fix_dynamic_routes_v3.py b/fix_dynamic_routes_v3.py deleted file mode 100644 index eb60e52..0000000 --- a/fix_dynamic_routes_v3.py +++ /dev/null @@ -1,65 +0,0 @@ -import os -import glob -import re - -routes = [ - "app/client/rate-review/[bookingId]", - "app/client/addresses/edit/[id]", - "app/client/order/cancel/[id]", - "app/client/craftsman/[id]", - "app/client/services/[categoryId]", - "app/client/booking/[workerId]", - "app/client/chat/[id]", - "app/client/wallet/edit-card/[id]", - "app/(main)/worker/booking/[id]", - "app/(main)/worker/messages/[id]", - "app/(onboarding)/intro/[step]" -] - -for route in routes: - old_page = os.path.join(route, "page.tsx") - client_page = os.path.join(route, "ClientPage.tsx") - layout_path = os.path.join(route, "layout.tsx") - - # Remove the layout.tsx if it exists - if os.path.exists(layout_path): - os.remove(layout_path) - print(f"Removed {layout_path}") - - # Rename page.tsx to ClientPage.tsx - if os.path.exists(old_page): - os.rename(old_page, client_page) - print(f"Renamed {old_page} to {client_page}") - - # Create a new page.tsx Server Component - if route == "app/(onboarding)/intro/[step]": - new_page_content = """import ClientPage from './ClientPage'; - -export function generateStaticParams() { - return [ - { step: '1' }, - { step: '2' }, - { step: '3' }, - { step: '4' } - ]; -} - -export default function Page() { - return ; -} -""" - else: - new_page_content = """import ClientPage from './ClientPage'; - -export function generateStaticParams() { - return []; -} - -export default function Page() { - return ; -} -""" - with open(old_page, 'w') as f: - f.write(new_page_content) - print(f"Created new {old_page}") - diff --git a/fix_dynamic_routes_v4.py b/fix_dynamic_routes_v4.py deleted file mode 100644 index 2944040..0000000 --- a/fix_dynamic_routes_v4.py +++ /dev/null @@ -1,44 +0,0 @@ -import os -import glob -import re - -routes = [ - "app/client/rate-review/[bookingId]", - "app/client/addresses/edit/[id]", - "app/client/order/cancel/[id]", - "app/client/craftsman/[id]", - "app/client/services/[categoryId]", - "app/client/booking/[workerId]", - "app/client/chat/[id]", - "app/client/wallet/edit-card/[id]", - "app/(main)/worker/booking/[id]", - "app/(main)/worker/messages/[id]", - "app/(onboarding)/intro/[step]" -] - -for route in routes: - page_path = os.path.join(route, "page.tsx") - - # extract parameter name - match = re.search(r'\[([^\]]+)\]$', route) - if not match: - continue - param_name = match.group(1) - - if route == "app/(onboarding)/intro/[step]": - continue # intro is already fine with 1, 2, 3, 4 - - new_page_content = f"""import ClientPage from './ClientPage'; - -export function generateStaticParams() {{ - return [{{ {param_name}: 'dummy' }}]; -}} - -export default function Page() {{ - return ; -}} -""" - with open(page_path, 'w') as f: - f.write(new_page_content) - print(f"Updated {page_path} to return [{{ {param_name}: 'dummy' }}]") - diff --git a/fix_dynamic_routes_v6.py b/fix_dynamic_routes_v6.py deleted file mode 100644 index bd30e66..0000000 --- a/fix_dynamic_routes_v6.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -import glob -import re - -routes = [ - "app/client/rate-review/[bookingId]", - "app/client/addresses/edit/[id]", - "app/client/order/cancel/[id]", - "app/client/craftsman/[id]", - "app/client/services/[categoryId]", - "app/client/booking/[workerId]", - "app/client/chat/[id]", - "app/client/wallet/edit-card/[id]", - "app/(main)/worker/booking/[id]", - "app/(main)/worker/messages/[id]", - "app/(onboarding)/intro/[step]" -] - -for route in routes: - old_page = os.path.join(route, "page.tsx") - client_page = os.path.join(route, "ClientPage.tsx") - - # Rename page.tsx to ClientPage.tsx if it's the original one (i.e. has 'use client' at the top) - if os.path.exists(old_page): - with open(old_page, 'r') as f: - content = f.read() - - if 'use client' in content: - os.rename(old_page, client_page) - print(f"Renamed {old_page} to {client_page}") - - # extract parameter name - match = re.search(r'\[([^\]]+)\]$', route) - param_name = match.group(1) if match else "id" - - if route == "app/(onboarding)/intro/[step]": - new_page_content = f"""import ClientPage from './ClientPage'; - -export function generateStaticParams() {{ - return [ - {{ step: '1' }}, - {{ step: '2' }}, - {{ step: '3' }}, - {{ step: '4' }} - ]; -}} - -export default function Page({{ params }}: {{ params: any }}) {{ - return ; -}} -""" - else: - new_page_content = f"""import ClientPage from './ClientPage'; - -export function generateStaticParams() {{ - return [{{ {param_name}: 'dummy' }}]; -}} - -export default function Page({{ params }}: {{ params: any }}) {{ - return ; -}} -""" - with open(old_page, 'w') as f: - f.write(new_page_content) - print(f"Created new {old_page}") - else: - print(f"{old_page} is already a Server Component") diff --git a/hooks/useClientHome.ts b/hooks/useClientHome.ts index 5332cc2..5cb1a6a 100644 --- a/hooks/useClientHome.ts +++ b/hooks/useClientHome.ts @@ -23,6 +23,7 @@ export interface WorkerCard { is_available: boolean governorate?: string | null area?: string | null + workerPlan?: 'basic' | 'pro' | 'master' | 'premium' } export function useClientHome() { @@ -49,6 +50,20 @@ export function useClientHome() { if (catRes.data) setCategories(catRes.data) if (workRes.data) { let result = [...workRes.data] as WorkerCard[] + + // Fetch subscriptions for these workers + const workerIds = result.map(w => w.id) + const { data: subs } = await supabase + .from('worker_subscriptions') + .select('worker_id, plan_type') + .in('worker_id', workerIds) + .eq('status', 'active') + + const subMap = new Map(subs?.map(s => [s.worker_id, s.plan_type]) || []) + result = result.map(w => ({ ...w, workerPlan: subMap.get(w.id) || 'basic' })) + + const planScore = { premium: 4, master: 3, pro: 2, basic: 1 } + if (clientProfile) { const getProximityScore = (w: WorkerCard) => { if (w.governorate === clientProfile.governorate && w.area === clientProfile.area) return 3 @@ -56,9 +71,24 @@ export function useClientHome() { return 1 } result.sort((a, b) => { + // Sort by plan tier first + 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 + + // Then by proximity const scoreA = getProximityScore(a) const scoreB = getProximityScore(b) if (scoreA !== scoreB) return scoreB - scoreA + + // Then by rating + return (b.rating || 0) - (a.rating || 0) + }) + } 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) }) } diff --git a/hooks/useHome.ts b/hooks/useHome.ts index 308ed08..8914296 100644 --- a/hooks/useHome.ts +++ b/hooks/useHome.ts @@ -11,6 +11,7 @@ export function useHome() { const [isAvailable, setIsAvailable] = useState(true) const [activeEmergency, setActiveEmergency] = useState(null) + const [workerSub, setWorkerSub] = useState(null) const fetchBookings = useCallback(async () => { if (!profile?.id) return @@ -46,6 +47,17 @@ export function useHome() { .maybeSingle() setActiveEmergency(data) + + // Fetch worker subscription + const { data: subData } = await supabase + .from('worker_subscriptions') + .select('plan_type') + .eq('worker_id', profile.id) + .eq('status', 'active') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle() + setWorkerSub(subData?.plan_type || 'basic') }, [profile?.id, profile?.profession, isAvailable, supabase]) useEffect(() => { @@ -179,6 +191,7 @@ export function useHome() { acceptEmergency, toggleAvailability, handleRequest, - handleLogout + handleLogout, + workerSub } } diff --git a/lib/supabase/booking-payments.ts b/lib/supabase/booking-payments.ts index 4ddc96e..564a741 100644 --- a/lib/supabase/booking-payments.ts +++ b/lib/supabase/booking-payments.ts @@ -12,7 +12,26 @@ export async function processBookingCompletion(supabase: SupabaseClient, booking } const price = booking.price || 0 - const fee = price * 0.15 + + // Fetch active worker subscription to determine commission fee + const { data: workerSub } = await supabase + .from('worker_subscriptions') + .select('plan_type') + .eq('worker_id', booking.worker_id) + .eq('status', 'active') + .order('created_at', { ascending: false }) + .limit(1) + .single() + + let feePercentage = 0.15 // Default basic + let planName = 'الأساسية' + if (workerSub) { + if (workerSub.plan_type === 'premium') { feePercentage = 0; planName = 'بريميوم' } + else if (workerSub.plan_type === 'master') { feePercentage = 0.05; planName = 'ماستر' } + else if (workerSub.plan_type === 'pro') { feePercentage = 0.1; planName = 'برو' } + } + + const fee = price * feePercentage const workerEarnings = price - fee const { data: workerProfile, error: workerErr } = await supabase @@ -44,7 +63,7 @@ export async function processBookingCompletion(supabase: SupabaseClient, booking user_id: booking.worker_id, type: 'payment', amount: -fee, - description: `خصم عمولة المنصة (15%) للطلب #${booking.id}` + description: `خصم عمولة المنصة (${feePercentage * 100}%) لباقة ${planName} للطلب #${booking.id}` }) } else { const newBalance = currentBalance + workerEarnings diff --git a/lib/supabase/subscriptions.ts b/lib/supabase/subscriptions.ts new file mode 100644 index 0000000..d837196 --- /dev/null +++ b/lib/supabase/subscriptions.ts @@ -0,0 +1,99 @@ +import { createClient } from './client'; + +export type PlanType = 'free' | 'care' | 'shield' | 'estate'; +export type SubscriptionStatus = 'active' | 'cancelled' | 'expired'; + +export interface ClientSubscription { + id: string; + user_id: string; + plan_type: PlanType; + status: SubscriptionStatus; + start_date: string; + end_date: string | null; + created_at: string; +} + +/** + * Fetch the active subscription for a user. + * Defaults to 'free' if no active subscription is found. + */ +export async function getActiveSubscription(userId: string): Promise { + const supabase = createClient(); + try { + const { data, error } = await supabase + .from('client_subscriptions') + .select('*') + .eq('user_id', userId) + .eq('status', 'active') + .order('created_at', { ascending: false }) + .limit(1) + .single(); + + if (error && error.code !== 'PGRST116') { + console.error('Error fetching subscription:', error); + return null; + } + + if (!data) { + // Return a default "free" subscription if none exists + return { + id: 'default-free', + user_id: userId, + plan_type: 'free', + status: 'active', + start_date: new Date().toISOString(), + end_date: null, + created_at: new Date().toISOString(), + }; + } + + return data as ClientSubscription; + } catch (err) { + console.error('Unexpected error fetching subscription:', err); + return null; + } +} + +/** + * Subscribes a user to a specific plan. + * Cancels any existing active subscriptions first. + */ +export async function subscribeToPlan(userId: string, planType: PlanType): Promise<{ success: boolean; error?: string }> { + const supabase = createClient(); + try { + // 1. Cancel existing active subscriptions + await supabase + .from('client_subscriptions') + .update({ status: 'cancelled', end_date: new Date().toISOString() }) + .eq('user_id', userId) + .eq('status', 'active'); + + // 2. Create new subscription with 30 days duration (if paid) + const startDate = new Date(); + let endDate: Date | null = null; + if (planType !== 'free') { + endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + 30); + } + + const { error } = await supabase + .from('client_subscriptions') + .insert({ + user_id: userId, + plan_type: planType, + status: 'active', + start_date: startDate.toISOString(), + end_date: endDate ? endDate.toISOString() : null, + }); + + if (error) { + console.error('Error inserting subscription:', error); + return { success: false, error: error.message }; + } + + return { success: true }; + } catch (err: any) { + console.error('Unexpected error during subscription:', err); + return { success: false, error: err.message || 'An unexpected error occurred' }; + } +} diff --git a/lib/supabase/worker-subscriptions.ts b/lib/supabase/worker-subscriptions.ts new file mode 100644 index 0000000..12b92fa --- /dev/null +++ b/lib/supabase/worker-subscriptions.ts @@ -0,0 +1,99 @@ +import { createClient } from './client'; + +export type WorkerPlanType = 'basic' | 'pro' | 'master' | 'premium'; +export type WorkerSubscriptionStatus = 'active' | 'cancelled' | 'expired'; + +export interface WorkerSubscription { + id: string; + worker_id: string; + plan_type: WorkerPlanType; + status: WorkerSubscriptionStatus; + start_date: string; + end_date: string | null; + created_at: string; +} + +/** + * Fetch the active subscription for a worker. + * Defaults to 'basic' if no active subscription is found. + */ +export async function getActiveWorkerSubscription(workerId: string): Promise { + const supabase = createClient(); + try { + const { data, error } = await supabase + .from('worker_subscriptions') + .select('*') + .eq('worker_id', workerId) + .eq('status', 'active') + .order('created_at', { ascending: false }) + .limit(1) + .single(); + + if (error && error.code !== 'PGRST116') { + console.error('Error fetching worker subscription:', error); + return null; + } + + if (!data) { + // Return a default "basic" subscription if none exists + return { + id: 'default-basic', + worker_id: workerId, + plan_type: 'basic', + status: 'active', + start_date: new Date().toISOString(), + end_date: null, + created_at: new Date().toISOString(), + }; + } + + return data as WorkerSubscription; + } catch (err) { + console.error('Unexpected error fetching worker subscription:', err); + return null; + } +} + +/** + * Subscribes a worker to a specific plan. + * Cancels any existing active subscriptions first. + */ +export async function subscribeWorkerToPlan(workerId: string, planType: WorkerPlanType): Promise<{ success: boolean; error?: string }> { + const supabase = createClient(); + try { + // 1. Cancel existing active subscriptions + await supabase + .from('worker_subscriptions') + .update({ status: 'cancelled', end_date: new Date().toISOString() }) + .eq('worker_id', workerId) + .eq('status', 'active'); + + // 2. Create new subscription with 30 days duration (if paid) + const startDate = new Date(); + let endDate: Date | null = null; + if (planType !== 'basic') { + endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + 30); + } + + const { error } = await supabase + .from('worker_subscriptions') + .insert({ + worker_id: workerId, + plan_type: planType, + status: 'active', + start_date: startDate.toISOString(), + end_date: endDate ? endDate.toISOString() : null, + }); + + if (error) { + console.error('Error inserting worker subscription:', error); + return { success: false, error: error.message }; + } + + return { success: true }; + } catch (err: any) { + console.error('Unexpected error during worker subscription:', err); + return { success: false, error: err.message || 'An unexpected error occurred' }; + } +} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.