From 1f3b435cd679d276544bb70819a1612458e7fd6c Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Fri, 8 May 2026 00:33:01 +0530 Subject: [PATCH 1/2] edit packages --- src/App.jsx | 136 +++++- src/api/CoachApi/packages.js | 18 +- src/components/dashboard/DashboardPage.jsx | 4 + .../dashboard/sections/PackagesSection.jsx | 406 ++++++++++++------ 4 files changed, 421 insertions(+), 143 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index e463934..6994801 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,7 +11,11 @@ import { useCoachStudents } from './hooks/useCoachStudents'; import useCoachProfile from './hooks/useCoachProfile'; import useAuth from './hooks/useAuth.jsx'; import { createDefaultProfile } from './constants/profile'; -import { listCoachPackages } from './api/CoachApi/packages'; +import { + deleteCoachPackage, + listCoachPackages, + updateCoachPackage +} from './api/CoachApi/packages'; import { addCoachCustomLocation, deleteCoachLocation, @@ -67,6 +71,20 @@ const resolvePackagesFromPayload = (payload) => { return []; }; +const resolvePackageFromPayload = (payload) => { + if (!payload || typeof payload !== 'object') { + return null; + } + + return payload.package || payload.data?.package || payload.data || payload.result || payload.item || payload; +}; + +const getApiErrorMessage = (errorBody, fallbackMessage) => + errorBody?.message || + errorBody?.error || + errorBody?.errors?.[0] || + fallbackMessage; + const defaultProfile = createDefaultProfile(); const formatDuration = (duration) => { @@ -1395,6 +1413,120 @@ function App() { packagesFetchedRef.current = true; }; + const handlePackageUpdated = useCallback((updatedPackage, packageId, fallbackChanges = {}) => { + setProfileData((previousProfile) => { + const previousPackages = Array.isArray(previousProfile.packages) + ? previousProfile.packages + : []; + + return { + ...previousProfile, + packages: previousPackages.map((existingPackage) => { + const existingId = existingPackage?.id ?? existingPackage?.package_id; + + if (String(existingId) !== String(packageId)) { + return existingPackage; + } + + if (updatedPackage && typeof updatedPackage === 'object') { + return { + ...existingPackage, + ...updatedPackage + }; + } + + return { + ...existingPackage, + ...fallbackChanges + }; + }) + }; + }); + + setPackagesError(null); + packagesFetchedRef.current = true; + }, []); + + const handlePackageArchiveToggle = useCallback(async (packageId, isActive) => { + try { + const response = await updateCoachPackage(packageId, { isActive }); + + if (!response) { + return { ok: false, error: 'Your session has expired. Please sign in again.' }; + } + + if (!response.ok) { + let message = 'Failed to update package. Please try again.'; + + try { + const errorBody = await response.json(); + message = getApiErrorMessage(errorBody, message); + } catch { + // Ignore JSON parse errors. + } + + return { ok: false, error: message }; + } + + const payload = await response.json().catch(() => null); + const updatedPackage = resolvePackageFromPayload(payload); + + handlePackageUpdated(updatedPackage, packageId, { is_active: isActive, isActive }); + return { ok: true }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to update package.' + }; + } + }, [handlePackageUpdated]); + + const handlePackageDelete = useCallback(async (packageId) => { + try { + const response = await deleteCoachPackage(packageId); + + if (!response) { + return { ok: false, error: 'Your session has expired. Please sign in again.' }; + } + + if (!response.ok) { + let message = 'Failed to delete package. Please try again.'; + + try { + const errorBody = await response.json(); + message = getApiErrorMessage(errorBody, message); + } catch { + // Ignore JSON parse errors. + } + + return { ok: false, error: message }; + } + + setProfileData((previousProfile) => { + const previousPackages = Array.isArray(previousProfile.packages) + ? previousProfile.packages + : []; + + return { + ...previousProfile, + packages: previousPackages.filter((existingPackage) => { + const existingId = existingPackage?.id ?? existingPackage?.package_id; + return String(existingId) !== String(packageId); + }) + }; + }); + + setPackagesError(null); + packagesFetchedRef.current = true; + return { ok: true }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to delete package.' + }; + } + }, []); + const handleOnboardingComplete = async (data) => { try { const result = await saveProfile(data); @@ -1688,6 +1820,8 @@ function App() { packagesLoading={packagesLoading} packagesError={packagesError} onRefreshPackages={refreshPackages} + onTogglePackageActive={handlePackageArchiveToggle} + onDeletePackage={handlePackageDelete} locationsData={coachLocations} locationsLoading={locationsLoading} locationsError={locationsError} diff --git a/src/api/CoachApi/packages.js b/src/api/CoachApi/packages.js index 85fd7fe..f127984 100644 --- a/src/api/CoachApi/packages.js +++ b/src/api/CoachApi/packages.js @@ -3,11 +3,27 @@ import { apiRequest } from '../apiRequest'; const buildPackagesPath = (includeInactive) => includeInactive ? '/coach/packages?includeInactive=true' : '/coach/packages'; +const buildPackagePath = (id) => `/coach/packages/${id}`; + export const listCoachPackages = async ({ includeInactive = false } = {}) => apiRequest(buildPackagesPath(includeInactive), { method: 'GET' }); +export const updateCoachPackage = async (id, payload) => + apiRequest(buildPackagePath(id), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + +export const deleteCoachPackage = async (id) => + apiRequest(buildPackagePath(id), { + method: 'DELETE' + }); + export default { - listCoachPackages + listCoachPackages, + updateCoachPackage, + deleteCoachPackage }; diff --git a/src/components/dashboard/DashboardPage.jsx b/src/components/dashboard/DashboardPage.jsx index 302ed42..e674549 100644 --- a/src/components/dashboard/DashboardPage.jsx +++ b/src/components/dashboard/DashboardPage.jsx @@ -421,6 +421,8 @@ const DashboardPage = ({ packagesLoading = false, packagesError = null, onRefreshPackages = () => {}, + onTogglePackageActive = async () => ({ ok: false }), + onDeletePackage = async () => ({ ok: false }), locationsData = [], locationsLoading = false, locationsError = null, @@ -1554,6 +1556,8 @@ const DashboardPage = ({ packagesError={packagesError} onRefreshPackages={onRefreshPackages} onOpenCreatePackage={onOpenCreatePackage} + onTogglePackageActive={onTogglePackageActive} + onDeletePackage={onDeletePackage} currencyFormatter={currencyFormatter} formatLessonTypeLabel={formatLessonTypeLabel} formatValidityLabel={formatValidityLabel} diff --git a/src/components/dashboard/sections/PackagesSection.jsx b/src/components/dashboard/sections/PackagesSection.jsx index 6c2df26..ebe9ba4 100644 --- a/src/components/dashboard/sections/PackagesSection.jsx +++ b/src/components/dashboard/sections/PackagesSection.jsx @@ -1,5 +1,6 @@ -import React from 'react'; -import { Package, RefreshCw } from 'lucide-react'; +import React, { useState } from 'react'; +import { Archive, Package, RefreshCw, RotateCcw, Trash2 } from 'lucide-react'; +import ConfirmationDialog from '../../modals/ConfirmationDialog'; const PackagesSection = ({ packages, @@ -7,155 +8,278 @@ const PackagesSection = ({ packagesError, onRefreshPackages, onOpenCreatePackage, + onTogglePackageActive, + onDeletePackage, currencyFormatter, formatLessonTypeLabel, formatValidityLabel -}) => ( -
-
-
-
-

Lesson Packages

-

- Create and manage lesson bundles for your students -

-
- -
+}) => { + const [packageActionError, setPackageActionError] = useState(''); + const [pendingPackageAction, setPendingPackageAction] = useState(null); + const [packagePendingDelete, setPackagePendingDelete] = useState(null); -
- {packagesLoading && packages.length === 0 && ( -
- -

Loading packages...

-
- )} - - {!packagesLoading && packagesError && ( -
-

We couldn't load your packages.

-

{packagesError}

- -
- )} + const handleTogglePackage = async (lessonPackage) => { + if (!lessonPackage?.id || pendingPackageAction) { + return; + } + + setPendingPackageAction({ + id: lessonPackage.id, + type: lessonPackage.isActive ? 'archive' : 'restore' + }); + setPackageActionError(''); + + const result = await onTogglePackageActive(lessonPackage.id, !lessonPackage.isActive); + + if (!result?.ok) { + setPackageActionError(result?.error || 'Failed to update package.'); + } + + setPendingPackageAction(null); + }; + + const handleConfirmDelete = async () => { + if (!packagePendingDelete?.id || pendingPackageAction) { + return; + } - {!packagesLoading && !packagesError && packages.length === 0 ? ( -
-

No packages yet

-

- Create your first lesson bundle to offer students multi-lesson savings. + setPendingPackageAction({ + id: packagePendingDelete.id, + type: 'delete' + }); + setPackageActionError(''); + + const result = await onDeletePackage(packagePendingDelete.id); + + if (!result?.ok) { + setPackageActionError(result?.error || 'Failed to delete package.'); + } else { + setPackagePendingDelete(null); + } + + setPendingPackageAction(null); + }; + + return ( +

+
+
+
+

Lesson Packages

+

+ Create and manage lesson bundles for your students

-
- ) : null} - - {packages.length > 0 && - packages.map((lessonPackage) => ( -
-
-
-
-

- {lessonPackage.name} -

- {!lessonPackage.isActive && ( - - Archived - - )} + +
+ +
+ Purchased packages are locked. Coaches can only archive or restore them through edit. +
+ +
+ {packageActionError && ( +
+ {packageActionError} +
+ )} + + {packagesLoading && packages.length === 0 && ( +
+ +

Loading packages...

+
+ )} + + {!packagesLoading && packagesError && ( +
+

We couldn't load your packages.

+

{packagesError}

+ +
+ )} + + {!packagesLoading && !packagesError && packages.length === 0 ? ( +
+

No packages yet

+

+ Create your first lesson bundle to offer students multi-lesson savings. +

+ +
+ ) : null} + + {packages.length > 0 && + packages.map((lessonPackage) => { + const isPending = pendingPackageAction?.id === lessonPackage.id; + const isArchiving = isPending && pendingPackageAction?.type === 'archive'; + const isRestoring = isPending && pendingPackageAction?.type === 'restore'; + const isDeleting = isPending && pendingPackageAction?.type === 'delete'; + + return ( +
+
+
+
+

+ {lessonPackage.name} +

+ {!lessonPackage.isActive && ( + + Archived + + )} +
+ {lessonPackage.description && ( +

+ {lessonPackage.description} +

+ )} +
+
+
+ {lessonPackage.lessonTypes.length > 0 ? ( + lessonPackage.lessonTypes.map((type) => ( + + {formatLessonTypeLabel(type)} + + )) + ) : ( + + Any lesson type + + )} +
+
+ + +
+
- {lessonPackage.description && ( -

- {lessonPackage.description} -

- )} -
-
- {lessonPackage.lessonTypes.length > 0 ? ( - lessonPackage.lessonTypes.map((type) => ( - +
+

Price

+

+ {lessonPackage.totalPrice !== null + ? currencyFormatter.format(lessonPackage.totalPrice) + : 'N/A'} +

+ {lessonPackage.perLessonPrice !== null && ( +

+ {currencyFormatter.format(lessonPackage.perLessonPrice)} per lesson +

+ )} +
+
+

Lessons Included

+

+ {lessonPackage.lessonCount !== null + ? `${lessonPackage.lessonCount} lessons` + : 'N/A'} +

+
+
+

Validity

+

+ {formatValidityLabel(lessonPackage.validityMonths)} +

+
+
+

Status

+

- {formatLessonTypeLabel(type)} - - )) - ) : ( - - Any lesson type - - )} -

-
- -
-
-

Price

-

- {lessonPackage.totalPrice !== null - ? currencyFormatter.format(lessonPackage.totalPrice) - : 'N/A'} -

- {lessonPackage.perLessonPrice !== null && ( -

- {currencyFormatter.format(lessonPackage.perLessonPrice)} per lesson -

- )} -
-
-

Lessons Included

-

- {lessonPackage.lessonCount !== null - ? `${lessonPackage.lessonCount} lessons` - : 'N/A'} -

-
-
-

Validity

-

- {formatValidityLabel(lessonPackage.validityMonths)} -

-
-
-

Status

-

- {lessonPackage.isActive ? 'Active' : 'Archived'} -

+ {lessonPackage.isActive ? 'Active' : 'Archived'} +

+
+
-
-
- ))} + ); + })} +
-
-
-); + + { + if (!pendingPackageAction) { + setPackagePendingDelete(null); + } + }} + tone="danger" + /> +
+ ); +}; export default PackagesSection; From 28afc4b9c04244a5f84067f701ade55abc24d243 Mon Sep 17 00:00:00 2001 From: sahilkashyap64 Date: Fri, 8 May 2026 00:39:31 +0530 Subject: [PATCH 2/2] Packages without purchases can be edited fully. Once purchased, only archive or restore is allowed. and View buyers --- src/App.jsx | 28 +- src/api/CoachApi/packages.js | 32 +- src/components/dashboard/DashboardPage.jsx | 14 +- .../dashboard/sections/PackagesSection.jsx | 57 ++- src/components/modals/CreatePackageModal.jsx | 189 +++++++--- .../modals/PackagePurchasesModal.jsx | 327 ++++++++++++++++++ 6 files changed, 593 insertions(+), 54 deletions(-) create mode 100644 src/components/modals/PackagePurchasesModal.jsx diff --git a/src/App.jsx b/src/App.jsx index 6994801..7ecbb43 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import OnboardingFlow from './components/onboarding/OnboardingFlow'; import AvailabilityModal from './components/modals/AvailabilityModal'; import ConfirmationDialog from './components/modals/ConfirmationDialog'; import CreatePackageModal from './components/modals/CreatePackageModal'; +import PackagePurchasesModal from './components/modals/PackagePurchasesModal'; import LessonDetailModal from './components/modals/LessonDetailModal'; import LoginPage from './components/auth/LoginPage'; import { useCoachSchedule } from './hooks/useCoachSchedule'; @@ -167,6 +168,8 @@ function App() { const [visibleCalendarDates, setVisibleCalendarDates] = useState([]); const [showAddLessonModal, setShowAddLessonModal] = useState(false); const [showCreatePackageModal, setShowCreatePackageModal] = useState(false); + const [selectedPackageForEdit, setSelectedPackageForEdit] = useState(null); + const [selectedPackageForPurchases, setSelectedPackageForPurchases] = useState(null); const [showLessonDetailModal, setShowLessonDetailModal] = useState(false); const [showCreateLessonModal, setShowCreateLessonModal] = useState(false); const [showLessonConfirmedSheet, setShowLessonConfirmedSheet] = useState(false); @@ -1527,6 +1530,11 @@ function App() { } }, []); + const handlePackageModalClose = useCallback(() => { + setShowCreatePackageModal(false); + setSelectedPackageForEdit(null); + }, []); + const handleOnboardingComplete = async (data) => { try { const result = await saveProfile(data); @@ -1806,7 +1814,15 @@ function App() { onEmptySlotSelect={handleEmptySlotSelect} onOpenAddAvailability={handleAddAvailabilityOpen} onOpenCreateLesson={handleCreateLessonOpen} - onOpenCreatePackage={() => setShowCreatePackageModal(true)} + onOpenCreatePackage={() => { + setSelectedPackageForEdit(null); + setShowCreatePackageModal(true); + }} + onEditPackage={(lessonPackage) => { + setSelectedPackageForEdit(lessonPackage); + setShowCreatePackageModal(true); + }} + onViewPackagePurchases={(lessonPackage) => setSelectedPackageForPurchases(lessonPackage)} onRequestAvailabilityOnboarding={handleRequestAvailabilityOnboarding} onOpenSettings={() => navigate('/settings')} onOpenNotifications={() => navigate('/notifications')} @@ -1902,8 +1918,16 @@ function App() { setShowCreatePackageModal(false)} + onClose={handlePackageModalClose} onCreated={handlePackageCreated} + onUpdated={handlePackageUpdated} + packageToEdit={selectedPackageForEdit} + /> + + setSelectedPackageForPurchases(null)} + lessonPackage={selectedPackageForPurchases} /> method: 'GET' }); +export const createCoachPackage = async (payload) => + apiRequest('/coach/packages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + export const updateCoachPackage = async (id, payload) => apiRequest(buildPackagePath(id), { method: 'PATCH', @@ -22,8 +29,31 @@ export const deleteCoachPackage = async (id) => method: 'DELETE' }); +export const getCoachPackagePurchases = async (id, params = {}) => { + const query = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value === null || value === undefined || value === '') { + return; + } + + query.set(key, String(value)); + }); + + const queryString = query.toString(); + const path = queryString + ? `${buildPackagePath(id)}/purchases?${queryString}` + : `${buildPackagePath(id)}/purchases`; + + return apiRequest(path, { + method: 'GET' + }); +}; + export default { + createCoachPackage, listCoachPackages, updateCoachPackage, - deleteCoachPackage + deleteCoachPackage, + getCoachPackagePurchases }; diff --git a/src/components/dashboard/DashboardPage.jsx b/src/components/dashboard/DashboardPage.jsx index e674549..8ff0c76 100644 --- a/src/components/dashboard/DashboardPage.jsx +++ b/src/components/dashboard/DashboardPage.jsx @@ -410,6 +410,8 @@ const DashboardPage = ({ onEmptySlotSelect, onOpenAddAvailability, onOpenCreatePackage, + onEditPackage = () => {}, + onViewPackagePurchases = () => {}, onOpenCreateLesson, onRequestAvailabilityOnboarding, onOpenSettings, @@ -494,7 +496,15 @@ const DashboardPage = ({ ? totalPrice / lessonCount : null, lessonTypes, - isActive: entry.is_active ?? entry.isActive ?? true + isActive: entry.is_active ?? entry.isActive ?? true, + hasPurchaseHistory: Boolean( + entry.has_purchase_history ?? entry.hasPurchaseHistory ?? false + ), + purchaseCount: parseNumber(entry.purchase_count ?? entry.purchaseCount) ?? 0, + lastPurchasedAt: + entry.last_purchased_at ?? + entry.lastPurchasedAt ?? + null }; }) .sort((a, b) => { @@ -1556,6 +1566,8 @@ const DashboardPage = ({ packagesError={packagesError} onRefreshPackages={onRefreshPackages} onOpenCreatePackage={onOpenCreatePackage} + onEditPackage={onEditPackage} + onViewPackagePurchases={onViewPackagePurchases} onTogglePackageActive={onTogglePackageActive} onDeletePackage={onDeletePackage} currencyFormatter={currencyFormatter} diff --git a/src/components/dashboard/sections/PackagesSection.jsx b/src/components/dashboard/sections/PackagesSection.jsx index ebe9ba4..9e06f83 100644 --- a/src/components/dashboard/sections/PackagesSection.jsx +++ b/src/components/dashboard/sections/PackagesSection.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Archive, Package, RefreshCw, RotateCcw, Trash2 } from 'lucide-react'; +import { Archive, Edit3, Package, RefreshCw, RotateCcw, Trash2, Users } from 'lucide-react'; import ConfirmationDialog from '../../modals/ConfirmationDialog'; const PackagesSection = ({ @@ -8,6 +8,8 @@ const PackagesSection = ({ packagesError, onRefreshPackages, onOpenCreatePackage, + onEditPackage, + onViewPackagePurchases, onTogglePackageActive, onDeletePackage, currencyFormatter, @@ -60,6 +62,23 @@ const PackagesSection = ({ setPendingPackageAction(null); }; + const formatPurchaseDate = (value) => { + if (!value) { + return 'Never'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Never'; + } + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }).format(date); + }; + return (
@@ -81,7 +100,7 @@ const PackagesSection = ({
- Purchased packages are locked. Coaches can only archive or restore them through edit. + Packages without purchases can be edited fully. Once purchased, only archive or restore is allowed.
@@ -136,6 +155,7 @@ const PackagesSection = ({ const isArchiving = isPending && pendingPackageAction?.type === 'archive'; const isRestoring = isPending && pendingPackageAction?.type === 'restore'; const isDeleting = isPending && pendingPackageAction?.type === 'delete'; + const isPurchased = lessonPackage.hasPurchaseHistory; return (
)} +
+ + {lessonPackage.purchaseCount} purchases + + + Last purchased {formatPurchaseDate(lessonPackage.lastPurchasedAt)} + + {isPurchased && ( + + Locked after purchase + + )} +
@@ -178,6 +211,26 @@ const PackagesSection = ({ )}
+ {!isPurchased && ( + + )} +