diff --git a/src/App.jsx b/src/App.jsx index e463934..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'; @@ -11,7 +12,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 +72,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) => { @@ -149,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); @@ -1395,6 +1416,125 @@ 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 handlePackageModalClose = useCallback(() => { + setShowCreatePackageModal(false); + setSelectedPackageForEdit(null); + }, []); + const handleOnboardingComplete = async (data) => { try { const result = await saveProfile(data); @@ -1674,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')} @@ -1688,6 +1836,8 @@ function App() { packagesLoading={packagesLoading} packagesError={packagesError} onRefreshPackages={refreshPackages} + onTogglePackageActive={handlePackageArchiveToggle} + onDeletePackage={handlePackageDelete} locationsData={coachLocations} locationsLoading={locationsLoading} locationsError={locationsError} @@ -1768,8 +1918,16 @@ function App() { setShowCreatePackageModal(false)} + onClose={handlePackageModalClose} onCreated={handlePackageCreated} + onUpdated={handlePackageUpdated} + packageToEdit={selectedPackageForEdit} + /> + + setSelectedPackageForPurchases(null)} + lessonPackage={selectedPackageForPurchases} /> 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 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', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + +export const deleteCoachPackage = async (id) => + apiRequest(buildPackagePath(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 { - listCoachPackages + createCoachPackage, + listCoachPackages, + updateCoachPackage, + deleteCoachPackage, + getCoachPackagePurchases }; diff --git a/src/components/dashboard/DashboardPage.jsx b/src/components/dashboard/DashboardPage.jsx index 302ed42..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, @@ -421,6 +423,8 @@ const DashboardPage = ({ packagesLoading = false, packagesError = null, onRefreshPackages = () => {}, + onTogglePackageActive = async () => ({ ok: false }), + onDeletePackage = async () => ({ ok: false }), locationsData = [], locationsLoading = false, locationsError = null, @@ -492,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) => { @@ -1554,6 +1566,10 @@ const DashboardPage = ({ packagesError={packagesError} onRefreshPackages={onRefreshPackages} onOpenCreatePackage={onOpenCreatePackage} + onEditPackage={onEditPackage} + onViewPackagePurchases={onViewPackagePurchases} + 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..9e06f83 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, Edit3, Package, RefreshCw, RotateCcw, Trash2, Users } from 'lucide-react'; +import ConfirmationDialog from '../../modals/ConfirmationDialog'; const PackagesSection = ({ packages, @@ -7,155 +8,331 @@ const PackagesSection = ({ packagesError, onRefreshPackages, onOpenCreatePackage, + onEditPackage, + onViewPackagePurchases, + 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; + } + + setPendingPackageAction({ + id: packagePendingDelete.id, + type: 'delete' + }); + setPackageActionError(''); - {!packagesLoading && !packagesError && packages.length === 0 ? ( -
-

No packages yet

-

- Create your first lesson bundle to offer students multi-lesson savings. + const result = await onDeletePackage(packagePendingDelete.id); + + if (!result?.ok) { + setPackageActionError(result?.error || 'Failed to delete package.'); + } else { + setPackagePendingDelete(null); + } + + 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 ( +

+
+
+
+

Lesson Packages

+

+ Create and manage lesson bundles for your students

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

- {lessonPackage.name} -

- {!lessonPackage.isActive && ( - - Archived - - )} + +
+ +
+ Packages without purchases can be edited fully. Once purchased, only archive or restore is allowed. +
+ +
+ {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'; + const isPurchased = lessonPackage.hasPurchaseHistory; + + return ( +
+
+
+
+

+ {lessonPackage.name} +

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

+ {lessonPackage.description} +

+ )} +
+ + {lessonPackage.purchaseCount} purchases + + + Last purchased {formatPurchaseDate(lessonPackage.lastPurchasedAt)} + + {isPurchased && ( + + Locked after purchase + + )} +
+
+
+
+ {lessonPackage.lessonTypes.length > 0 ? ( + lessonPackage.lessonTypes.map((type) => ( + + {formatLessonTypeLabel(type)} + + )) + ) : ( + + Any lesson type + + )} +
+
+ {!isPurchased && ( + + )} + + + +
+
- {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; diff --git a/src/components/modals/CreatePackageModal.jsx b/src/components/modals/CreatePackageModal.jsx index 1df817e..2af29d7 100644 --- a/src/components/modals/CreatePackageModal.jsx +++ b/src/components/modals/CreatePackageModal.jsx @@ -1,5 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { apiRequest } from '../../api/apiRequest'; +import { + createCoachPackage, + updateCoachPackage +} from '../../api/CoachApi/packages'; import Modal, { ModalBody, ModalFooter, ModalHeader } from './Modal'; const LESSON_TYPE_OPTIONS = [ @@ -8,32 +11,68 @@ const LESSON_TYPE_OPTIONS = [ { id: 'group', label: 'Group Classes' } ]; -const buildInitialFormState = () => ({ - name: '', - description: '', - lessonCount: '', - totalPrice: '', - validityMonths: '', - lessonTypesAllowed: ['private'] +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 buildInitialFormState = (lessonPackage) => ({ + name: lessonPackage?.name || '', + description: lessonPackage?.description || '', + lessonCount: + lessonPackage?.lessonCount === null || lessonPackage?.lessonCount === undefined + ? '' + : String(lessonPackage.lessonCount), + totalPrice: + lessonPackage?.totalPrice === null || lessonPackage?.totalPrice === undefined + ? '' + : String(lessonPackage.totalPrice), + validityMonths: + lessonPackage?.validityMonths === null + ? '0' + : lessonPackage?.validityMonths === undefined + ? '' + : String(lessonPackage.validityMonths), + lessonTypesAllowed: + Array.isArray(lessonPackage?.lessonTypes) && lessonPackage.lessonTypes.length > 0 + ? lessonPackage.lessonTypes + : ['private'], + isActive: lessonPackage?.isActive ?? true }); -const CreatePackageModal = ({ isOpen, onClose, onCreated = () => {} }) => { - const [formValues, setFormValues] = useState(buildInitialFormState); +const CreatePackageModal = ({ + isOpen, + onClose, + onCreated = () => {}, + onUpdated = () => {}, + packageToEdit = null +}) => { + const isEditMode = Boolean(packageToEdit?.id); + const isLockedAfterPurchase = Boolean(packageToEdit?.hasPurchaseHistory); + const [formValues, setFormValues] = useState(buildInitialFormState(packageToEdit)); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(''); - const lessonTypesSelected = useMemo(() => new Set(formValues.lessonTypesAllowed), [formValues.lessonTypesAllowed]); - - const resetForm = () => { - setFormValues(buildInitialFormState()); - setError(''); - }; + const lessonTypesSelected = useMemo( + () => new Set(formValues.lessonTypesAllowed), + [formValues.lessonTypesAllowed] + ); useEffect(() => { if (isOpen) { - resetForm(); + setFormValues(buildInitialFormState(packageToEdit)); + setError(''); } - }, [isOpen]); + }, [isOpen, packageToEdit]); const handleInputChange = (field) => (event) => { const { value } = event.target; @@ -44,6 +83,10 @@ const CreatePackageModal = ({ isOpen, onClose, onCreated = () => {} }) => { }; const toggleLessonType = (type) => { + if (isLockedAfterPurchase) { + return; + } + setFormValues((previous) => { const nextSelection = new Set(previous.lessonTypesAllowed); @@ -80,6 +123,10 @@ const CreatePackageModal = ({ isOpen, onClose, onCreated = () => {} }) => { return false; } + if (formValues.lessonTypesAllowed.length === 0) { + return false; + } + return true; }, [formValues]); @@ -98,15 +145,18 @@ const CreatePackageModal = ({ isOpen, onClose, onCreated = () => {} }) => { totalPrice: Number(formValues.totalPrice), validityMonths: formValues.validityMonths === '0' ? null : Number(formValues.validityMonths), - lessonTypesAllowed: formValues.lessonTypesAllowed + lessonTypesAllowed: formValues.lessonTypesAllowed, + isActive: Boolean(formValues.isActive) }; + const requestPayload = isLockedAfterPurchase + ? { isActive: payload.isActive } + : payload; + try { - const response = await apiRequest('/coach/packages', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); + const response = isEditMode + ? await updateCoachPackage(packageToEdit.id, requestPayload) + : await createCoachPackage(payload); if (!response) { setError('Your session has expired. Please sign in again.'); @@ -114,16 +164,14 @@ const CreatePackageModal = ({ isOpen, onClose, onCreated = () => {} }) => { } if (!response.ok) { - let message = 'Failed to create package. Please try again.'; + let message = isEditMode + ? 'Failed to update package. Please try again.' + : 'Failed to create package. Please try again.'; try { const errorBody = await response.json(); - message = - errorBody?.message || - errorBody?.error || - errorBody?.errors?.[0] || - message; - } catch (parseError) { + message = getApiErrorMessage(errorBody, message); + } catch { // Ignore JSON parse errors and keep the default message. } @@ -131,13 +179,19 @@ const CreatePackageModal = ({ isOpen, onClose, onCreated = () => {} }) => { return; } - const createdPackage = await response.json().catch(() => null); - onCreated(createdPackage); - resetForm(); + const responseBody = await response.json().catch(() => null); + const resolvedPackage = resolvePackageFromPayload(responseBody); + + if (isEditMode) { + onUpdated(resolvedPackage, packageToEdit.id, requestPayload); + } else { + onCreated(resolvedPackage); + } + onClose(); } catch (requestError) { setError('An unexpected error occurred. Please try again.'); - console.error('Failed to create package', requestError); + console.error('Failed to submit package', requestError); } finally { setIsSubmitting(false); } @@ -145,57 +199,70 @@ const CreatePackageModal = ({ isOpen, onClose, onCreated = () => {} }) => { return ( - +
- +
- +