From 44ae730ab7c90042ada1dd015bf724085bc66380 Mon Sep 17 00:00:00 2001 From: Muhmd <149331205+ByMuhmd@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:20:26 +0300 Subject: [PATCH 1/3] docs: improve README and add CONTRIBUTING guidelines --- CONTRIBUTING.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 36 +++++++++++++---- 2 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 CONTRIBUTING.md 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. --- From 7c3d593651344847f3ae63f3c0eac5f2513175ed Mon Sep 17 00:00:00 2001 From: Muhmd <149331205+ByMuhmd@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:22:20 +0300 Subject: [PATCH 2/3] chore: remove temporary route migration scripts and update capacitor production URL --- app.jpg | Bin 14770 -> 0 bytes capacitor.config.ts | 2 +- fix_dynamic_routes.py | 32 ------------------- fix_dynamic_routes_v2.py | 64 ------------------------------------- fix_dynamic_routes_v3.py | 65 ------------------------------------- fix_dynamic_routes_v4.py | 44 ------------------------- fix_dynamic_routes_v6.py | 67 --------------------------------------- 7 files changed, 1 insertion(+), 273 deletions(-) delete mode 100644 app.jpg delete mode 100644 fix_dynamic_routes.py delete mode 100644 fix_dynamic_routes_v2.py delete mode 100644 fix_dynamic_routes_v3.py delete mode 100644 fix_dynamic_routes_v4.py delete mode 100644 fix_dynamic_routes_v6.py diff --git a/app.jpg b/app.jpg deleted file mode 100644 index b01fd151d1369668658b8ce683610d42522b9e3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14770 zcmb7r2{@GN`|vx1a+D%QAxm1wl4DCLTUlBZ6JuWvLI~Nj xVX+y|zMxhxIvWIgL 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 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/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") From c96cc4178e523c00d9cc20aa39f1889efd48262d Mon Sep 17 00:00:00 2001 From: Muhmd <149331205+ByMuhmd@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:57:37 +0300 Subject: [PATCH 3/3] feat: implement subscription system for clients and workers with admin management dashboard and UI cards --- app/(main)/worker/home/page.tsx | 24 +- app/(main)/worker/profile/page.tsx | 31 ++- app/(main)/worker/subscriptions/page.tsx | 215 ++++++++++++++++++ app/admin/page.tsx | 124 +++++++++- app/api/admin/delete-notification/route.ts | 67 ++++++ app/client/home/page.tsx | 13 +- app/client/order/invoice/page.tsx | 53 ++++- app/client/orders/page.tsx | 22 +- app/client/profile/page.tsx | 23 +- app/client/subscriptions/page.tsx | 211 +++++++++++++++++ components/shared/CraftsmanCard.tsx | 30 ++- components/subscriptions/PricingCard.tsx | 82 +++++++ .../subscriptions/WorkerPricingCard.tsx | 87 +++++++ components/ui/orders/OrderCard.tsx | 55 ++++- hooks/useClientHome.ts | 30 +++ hooks/useHome.ts | 15 +- lib/supabase/booking-payments.ts | 23 +- lib/supabase/subscriptions.ts | 99 ++++++++ lib/supabase/worker-subscriptions.ts | 99 ++++++++ next-env.d.ts | 2 +- 20 files changed, 1269 insertions(+), 36 deletions(-) create mode 100644 app/(main)/worker/subscriptions/page.tsx create mode 100644 app/api/admin/delete-notification/route.ts create mode 100644 app/client/subscriptions/page.tsx create mode 100644 components/subscriptions/PricingCard.tsx create mode 100644 components/subscriptions/WorkerPricingCard.tsx create mode 100644 lib/supabase/subscriptions.ts create mode 100644 lib/supabase/worker-subscriptions.ts 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/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/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.