From cf91bae24fd1b546f9d83d7484ecd2b25a1c36a1 Mon Sep 17 00:00:00 2001 From: poduck Date: Thu, 4 Dec 2025 13:52:51 -0500 Subject: [PATCH] feat(services): Add deposit percentage option for fixed-price services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add deposit_percent field back to Service model for percentage-based deposits - Reorganize service form: variable pricing toggle at top, deposit toggle with amount/percent options (percent only available for fixed pricing) - Disable price field when variable pricing is enabled - Add backend validation: variable pricing cannot use percentage deposits - Update frontend types and hooks to handle deposit_percent field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- frontend/src/hooks/useServices.ts | 10 +- frontend/src/pages/Services.tsx | 225 +++++++++++++++--- frontend/src/types.ts | 5 +- .../0027_add_deposit_percent_back.py | 25 ++ smoothschedule/schedule/models.py | 39 ++- smoothschedule/schedule/serializers.py | 23 +- 6 files changed, 279 insertions(+), 48 deletions(-) create mode 100644 smoothschedule/schedule/migrations/0027_add_deposit_percent_back.py diff --git a/frontend/src/hooks/useServices.ts b/frontend/src/hooks/useServices.ts index cd91e2c..79af636 100644 --- a/frontend/src/hooks/useServices.ts +++ b/frontend/src/hooks/useServices.ts @@ -26,7 +26,8 @@ export const useServices = () => { photos: s.photos || [], // Pricing fields variable_pricing: s.variable_pricing ?? false, - deposit_amount: s.deposit_amount ? parseFloat(s.deposit_amount) : 0, + deposit_amount: s.deposit_amount ? parseFloat(s.deposit_amount) : null, + deposit_percent: s.deposit_percent ? parseFloat(s.deposit_percent) : null, requires_deposit: s.requires_deposit ?? false, requires_saved_payment_method: s.requires_saved_payment_method ?? false, deposit_display: s.deposit_display || null, @@ -68,7 +69,8 @@ interface ServiceInput { description?: string; photos?: string[]; variable_pricing?: boolean; - deposit_amount?: number; // 0 = no deposit + deposit_amount?: number | null; + deposit_percent?: number | null; } /** @@ -94,6 +96,9 @@ export const useCreateService = () => { if (serviceData.deposit_amount !== undefined) { backendData.deposit_amount = serviceData.deposit_amount; } + if (serviceData.deposit_percent !== undefined) { + backendData.deposit_percent = serviceData.deposit_percent; + } const { data } = await apiClient.post('/services/', backendData); return data; @@ -121,6 +126,7 @@ export const useUpdateService = () => { // Pricing fields if (updates.variable_pricing !== undefined) backendData.variable_pricing = updates.variable_pricing; if (updates.deposit_amount !== undefined) backendData.deposit_amount = updates.deposit_amount; + if (updates.deposit_percent !== undefined) backendData.deposit_percent = updates.deposit_percent; const { data } = await apiClient.patch(`/services/${id}/`, backendData); return data; diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx index 55d9cfd..7eff623 100644 --- a/frontend/src/pages/Services.tsx +++ b/frontend/src/pages/Services.tsx @@ -14,7 +14,10 @@ interface ServiceFormData { photos: string[]; // Pricing fields variable_pricing: boolean; - deposit_amount: number; // 0 = no deposit + deposit_enabled: boolean; + deposit_type: 'amount' | 'percent'; + deposit_amount: number | null; + deposit_percent: number | null; } const Services: React.FC = () => { @@ -41,7 +44,10 @@ const Services: React.FC = () => { description: '', photos: [], variable_pricing: false, - deposit_amount: 0, + deposit_enabled: false, + deposit_type: 'amount', + deposit_amount: null, + deposit_percent: null, }); // Photo gallery state @@ -209,13 +215,21 @@ const Services: React.FC = () => { description: '', photos: [], variable_pricing: false, - deposit_amount: 0, + deposit_enabled: false, + deposit_type: 'amount', + deposit_amount: null, + deposit_percent: null, }); setIsModalOpen(true); }; const openEditModal = (service: Service) => { setEditingService(service); + // Determine deposit configuration from existing data + const hasDeposit = (service.deposit_amount && service.deposit_amount > 0) || + (service.deposit_percent && service.deposit_percent > 0); + const depositType = service.deposit_percent && service.deposit_percent > 0 ? 'percent' : 'amount'; + setFormData({ name: service.name, durationMinutes: service.durationMinutes, @@ -223,7 +237,10 @@ const Services: React.FC = () => { description: service.description || '', photos: service.photos || [], variable_pricing: service.variable_pricing || false, - deposit_amount: service.deposit_amount || 0, + deposit_enabled: hasDeposit, + deposit_type: depositType, + deposit_amount: service.deposit_amount || null, + deposit_percent: service.deposit_percent || null, }); setIsModalOpen(true); }; @@ -236,14 +253,21 @@ const Services: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + // Build API data based on form state const apiData = { name: formData.name, durationMinutes: formData.durationMinutes, - price: formData.price, + price: formData.variable_pricing ? 0 : formData.price, // Price is 0 for variable pricing description: formData.description, photos: formData.photos, variable_pricing: formData.variable_pricing, - deposit_amount: formData.deposit_amount, + // Only send deposit values if deposit is enabled + deposit_amount: formData.deposit_enabled && formData.deposit_type === 'amount' + ? formData.deposit_amount + : null, + deposit_percent: formData.deposit_enabled && formData.deposit_type === 'percent' + ? formData.deposit_percent + : null, }; try { @@ -523,6 +547,40 @@ const Services: React.FC = () => {
+ {/* Variable Pricing Toggle - At the top */} +
+
+
+ +

+ {t('services.variablePricingDescription', 'Final price is determined after service completion')} +

+
+ +
+
+ + {/* Name */}
+ {/* Duration and Price */}
setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} required={!formData.variable_pricing} + disabled={formData.variable_pricing} min={0} step={0.01} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + placeholder={formData.variable_pricing ? t('services.priceNA', 'N/A') : '0.00'} + className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 ${ + formData.variable_pricing + ? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed' + : 'bg-white dark:bg-gray-700' + }`} /> + {formData.variable_pricing && ( +

+ {t('services.variablePriceNote', 'Price determined after service')} +

+ )}
- {/* Deposit Amount */} -
- - setFormData({ ...formData, deposit_amount: parseFloat(e.target.value) || 0 })} - min={0} - step={0.01} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" - placeholder="0.00" - /> -

- {t('services.depositHint', 'Set to 0 for no deposit. When set, customers must save a payment method to book.')} -

-
- - {/* Variable Pricing Toggle */} + {/* Deposit Toggle and Configuration */}

- {t('services.variablePricingDescription', 'Final price is determined after service completion')} + {t('services.depositDescription', 'Collect a deposit when customer books')}

- {formData.variable_pricing && ( -

- {t('services.variablePricingNote', 'The price above is shown as an estimate. The final price will be determined and charged after service completion.')} -

+ + {/* Deposit Configuration - only shown when enabled */} + {formData.deposit_enabled && ( +
+ {/* Deposit Type Selection - only show for fixed pricing */} + {!formData.variable_pricing && ( +
+ +
+ + +
+
+ )} + + {/* Amount Input */} + {(formData.variable_pricing || formData.deposit_type === 'amount') && ( +
+ + setFormData({ ...formData, deposit_amount: parseFloat(e.target.value) || null })} + required + min={1} + step={0.01} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + placeholder="50.00" + /> +
+ )} + + {/* Percent Input - only for fixed pricing */} + {!formData.variable_pricing && formData.deposit_type === 'percent' && ( +
+ + setFormData({ ...formData, deposit_percent: parseFloat(e.target.value) || null })} + required + min={1} + max={100} + step={1} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500" + placeholder="25" + /> + {formData.deposit_percent && formData.price > 0 && ( +

+ {t('services.depositCalculated', 'Deposit: ${amount}', { + amount: ((formData.price * formData.deposit_percent) / 100).toFixed(2) + })} +

+ )} +
+ )} + +

+ {t('services.depositNote', 'Customers must save a payment method to book this service.')} +

+
)}
+ {/* Description */}