- {/* Header */}
- {t('marketing.testimonials.title')}
+ Trusted by Modern Businesses
- {t('marketing.testimonials.subtitle')}
+ See why forward-thinking companies choose SmoothSchedule.
- {/* Testimonials Grid */}
{testimonials.map((testimonial, index) => (
@@ -169,98 +138,6 @@ const HomePage: React.FC = () => {
- {/* Pricing Preview Section */}
-
-
- {/* Header */}
-
-
- {t('marketing.pricing.title')}
-
-
- {t('marketing.pricing.subtitle')}
-
-
-
- {/* Pricing Cards Preview */}
-
- {/* Free */}
-
-
- {t('marketing.pricing.tiers.free.name')}
-
-
- {t('marketing.pricing.tiers.free.description')}
-
-
- $0
- {t('marketing.pricing.perMonth')}
-
-
- {t('marketing.pricing.getStarted')}
-
-
-
- {/* Professional - Highlighted */}
-
-
- {t('marketing.pricing.mostPopular')}
-
-
- {t('marketing.pricing.tiers.professional.name')}
-
-
- {t('marketing.pricing.tiers.professional.description')}
-
-
- $29
- {t('marketing.pricing.perMonth')}
-
-
- {t('marketing.pricing.getStarted')}
-
-
-
- {/* Business */}
-
-
- {t('marketing.pricing.tiers.business.name')}
-
-
- {t('marketing.pricing.tiers.business.description')}
-
-
- $79
- {t('marketing.pricing.perMonth')}
-
-
- {t('marketing.pricing.getStarted')}
-
-
-
-
- {/* View Full Pricing Link */}
-
-
- View full pricing details
-
-
-
-
-
-
{/* Final CTA */}
diff --git a/frontend/src/pages/marketing/PricingPage.tsx b/frontend/src/pages/marketing/PricingPage.tsx
index 68ff789..1c4aef1 100644
--- a/frontend/src/pages/marketing/PricingPage.tsx
+++ b/frontend/src/pages/marketing/PricingPage.tsx
@@ -1,122 +1,56 @@
-import React, { useState } from 'react';
+import React from 'react';
import { useTranslation } from 'react-i18next';
-import PricingCard from '../../components/marketing/PricingCard';
+import PricingTable from '../../components/marketing/PricingTable';
import FAQAccordion from '../../components/marketing/FAQAccordion';
import CTASection from '../../components/marketing/CTASection';
const PricingPage: React.FC = () => {
const { t } = useTranslation();
- const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'annual'>('monthly');
-
- const faqItems = [
- {
- question: t('marketing.faq.questions.freePlan.question'),
- answer: t('marketing.faq.questions.freePlan.answer'),
- },
- {
- question: t('marketing.faq.questions.cancel.question'),
- answer: t('marketing.faq.questions.cancel.answer'),
- },
- {
- question: t('marketing.faq.questions.payment.question'),
- answer: t('marketing.faq.questions.payment.answer'),
- },
- {
- question: t('marketing.faq.questions.migrate.question'),
- answer: t('marketing.faq.questions.migrate.answer'),
- },
- {
- question: t('marketing.faq.questions.support.question'),
- answer: t('marketing.faq.questions.support.answer'),
- },
- {
- question: t('marketing.faq.questions.customDomain.question'),
- answer: t('marketing.faq.questions.customDomain.answer'),
- },
- ];
return (
-
- {/* Header Section */}
-
-
-
-
- {t('marketing.pricing.title')}
-
-
- {t('marketing.pricing.subtitle')}
-
-
+
+ {/* Header */}
+
+
+ Simple, Transparent Pricing
+
+
+ Start for free, upgrade as you grow. No hidden fees.
+
+
- {/* Billing Toggle */}
-
-
-
- {t('marketing.pricing.monthly')}
-
- setBillingPeriod(billingPeriod === 'monthly' ? 'annual' : 'monthly')}
- className="relative flex-shrink-0 w-12 h-6 bg-brand-600 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
- aria-label="Toggle billing period"
- >
-
-
-
- {t('marketing.pricing.annual')}
-
-
- {billingPeriod === 'annual' && (
-
- {t('marketing.pricing.annualSave')}
-
- )}
-
-
- {/* Pricing Cards */}
-
-
-
+ {/* Pricing Table */}
+
{/* FAQ Section */}
-
-
-
-
- {t('marketing.faq.title')}
-
-
- {t('marketing.faq.subtitle')}
-
-
+
+
+ Frequently Asked Questions
+
+
+
-
-
-
-
- {/* CTA Section */}
-
+ {/* CTA */}
+
);
};
diff --git a/frontend/src/pages/marketing/SignupPage.tsx b/frontend/src/pages/marketing/SignupPage.tsx
index 92d0854..e85011c 100644
--- a/frontend/src/pages/marketing/SignupPage.tsx
+++ b/frontend/src/pages/marketing/SignupPage.tsx
@@ -317,7 +317,7 @@ const SignupPage: React.FC = () => {
{t('marketing.signup.success.yourUrl')}
- {formData.subdomain}.smoothschedule.com
+ {formData.subdomain}.{getBaseDomain()}
@@ -357,13 +357,12 @@ const SignupPage: React.FC = () => {
step.number
+ className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${currentStep > step.number
? 'bg-green-500 text-white'
: currentStep === step.number
- ? 'bg-brand-600 text-white'
- : 'bg-gray-200 dark:bg-gray-700 text-gray-400'
- }`}
+ ? 'bg-brand-600 text-white'
+ : 'bg-gray-200 dark:bg-gray-700 text-gray-400'
+ }`}
>
{currentStep > step.number ? (
@@ -372,22 +371,20 @@ const SignupPage: React.FC = () => {
)}
= step.number
+ className={`mt-2 text-xs font-medium hidden sm:block ${currentStep >= step.number
? 'text-gray-900 dark:text-white'
: 'text-gray-400'
- }`}
+ }`}
>
{step.title}
{index < steps.length - 1 && (
step.number
+ className={`flex-1 h-1 mx-2 rounded ${currentStep > step.number
? 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'
- }`}
+ }`}
/>
)}
@@ -419,11 +416,10 @@ const SignupPage: React.FC = () => {
onChange={handleInputChange}
autoComplete="organization"
placeholder={t('marketing.signup.businessInfo.namePlaceholder')}
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.businessName
+ className={`w-full px-4 py-3 rounded-xl border ${errors.businessName
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.businessName && (
{errors.businessName}
@@ -446,13 +442,12 @@ const SignupPage: React.FC = () => {
onChange={handleSubdomainChange}
autoComplete="off"
placeholder="your-business"
- className={`flex-1 px-4 py-3 rounded-l-xl border-y border-l ${
- errors.subdomain
+ className={`flex-1 px-4 py-3 rounded-l-xl border-y border-l ${errors.subdomain
? 'border-red-500 focus:ring-red-500'
: subdomainAvailable === true
- ? 'border-green-500 focus:ring-green-500'
- : 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ ? 'border-green-500 focus:ring-green-500'
+ : 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
.smoothschedule.com
@@ -508,11 +503,10 @@ const SignupPage: React.FC = () => {
onChange={handleInputChange}
autoComplete="address-line1"
placeholder={t('marketing.signup.businessInfo.addressLine1Placeholder')}
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.addressLine1
+ className={`w-full px-4 py-3 rounded-xl border ${errors.addressLine1
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.addressLine1 && (
{errors.addressLine1}
@@ -553,11 +547,10 @@ const SignupPage: React.FC = () => {
value={formData.city}
onChange={handleInputChange}
autoComplete="address-level2"
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.city
+ className={`w-full px-4 py-3 rounded-xl border ${errors.city
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.city && (
{errors.city}
@@ -578,11 +571,10 @@ const SignupPage: React.FC = () => {
value={formData.state}
onChange={handleInputChange}
autoComplete="address-level1"
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.state
+ className={`w-full px-4 py-3 rounded-xl border ${errors.state
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.state && (
{errors.state}
@@ -605,11 +597,10 @@ const SignupPage: React.FC = () => {
value={formData.postalCode}
onChange={handleInputChange}
autoComplete="postal-code"
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.postalCode
+ className={`w-full px-4 py-3 rounded-xl border ${errors.postalCode
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.postalCode && (
{errors.postalCode}
@@ -663,11 +654,10 @@ const SignupPage: React.FC = () => {
value={formData.firstName}
onChange={handleInputChange}
autoComplete="given-name"
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.firstName
+ className={`w-full px-4 py-3 rounded-xl border ${errors.firstName
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.firstName && (
{errors.firstName}
@@ -688,11 +678,10 @@ const SignupPage: React.FC = () => {
value={formData.lastName}
onChange={handleInputChange}
autoComplete="family-name"
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.lastName
+ className={`w-full px-4 py-3 rounded-xl border ${errors.lastName
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.lastName && (
{errors.lastName}
@@ -715,11 +704,10 @@ const SignupPage: React.FC = () => {
onChange={handleInputChange}
autoComplete="email"
placeholder="you@example.com"
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.email
+ className={`w-full px-4 py-3 rounded-xl border ${errors.email
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.email && (
{errors.email}
@@ -740,11 +728,10 @@ const SignupPage: React.FC = () => {
value={formData.password}
onChange={handleInputChange}
autoComplete="new-password"
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.password
+ className={`w-full px-4 py-3 rounded-xl border ${errors.password
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.password && (
{errors.password}
@@ -765,11 +752,10 @@ const SignupPage: React.FC = () => {
value={formData.confirmPassword}
onChange={handleInputChange}
autoComplete="new-password"
- className={`w-full px-4 py-3 rounded-xl border ${
- errors.confirmPassword
+ className={`w-full px-4 py-3 rounded-xl border ${errors.confirmPassword
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600 focus:ring-brand-500'
- } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
+ } bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:border-transparent transition-colors`}
/>
{errors.confirmPassword && (
{errors.confirmPassword}
@@ -791,11 +777,10 @@ const SignupPage: React.FC = () => {
key={plan.id}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, plan: plan.id }))}
- className={`relative text-left p-4 rounded-xl border-2 transition-all ${
- formData.plan === plan.id
+ className={`relative text-left p-4 rounded-xl border-2 transition-all ${formData.plan === plan.id
? 'border-brand-600 bg-brand-50 dark:bg-gray-800 dark:border-brand-500'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/50 hover:border-gray-300 dark:hover:border-gray-600'
- }`}
+ }`}
>
{plan.popular && (
@@ -817,11 +802,10 @@ const SignupPage: React.FC = () => {
{formData.plan === plan.id && (
diff --git a/frontend/src/pages/platform/PlatformBusinesses.tsx b/frontend/src/pages/platform/PlatformBusinesses.tsx
index af80543..8fc8631 100644
--- a/frontend/src/pages/platform/PlatformBusinesses.tsx
+++ b/frontend/src/pages/platform/PlatformBusinesses.tsx
@@ -1,11 +1,15 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Search, Filter, Eye, ShieldCheck, Ban, Pencil, Send, ChevronDown, ChevronRight, Building2 } from 'lucide-react';
-import { useBusinesses } from '../../hooks/usePlatform';
-import { PlatformBusiness } from '../../api/platform';
+import { Eye, ShieldCheck, Ban, Pencil, Send, ChevronDown, ChevronRight, Building2, Check } from 'lucide-react';
+import { useBusinesses, useUpdateBusiness } from '../../hooks/usePlatform';
+import { PlatformBusiness, verifyUserEmail } from '../../api/platform';
import TenantInviteModal from './components/TenantInviteModal';
-import BusinessEditModal from './components/BusinessEditModal';
import { getBaseDomain } from '../../utils/domain';
+import PlatformListing from './components/PlatformListing';
+import PlatformTable from './components/PlatformTable';
+import PlatformListRow from './components/PlatformListRow';
+import EditPlatformEntityModal from './components/EditPlatformEntityModal';
+import { useQueryClient } from '@tanstack/react-query';
interface PlatformBusinessesProps {
onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
@@ -13,6 +17,7 @@ interface PlatformBusinessesProps {
const PlatformBusinesses: React.FC
= ({ onMasquerade }) => {
const { t } = useTranslation();
+ const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const { data: businesses, isLoading, error } = useBusinesses();
@@ -42,67 +47,58 @@ const PlatformBusinesses: React.FC = ({ onMasquerade })
}
};
+ const handleVerifyEmail = async (userId: number) => {
+ if (confirm(t('platform.confirmVerifyEmail'))) {
+ try {
+ await verifyUserEmail(userId);
+ queryClient.invalidateQueries({ queryKey: ['platform', 'businesses'] });
+ } catch (error) {
+ alert(t('errors.generic'));
+ }
+ }
+ };
+
// Helper to render business row
const renderBusinessRow = (business: PlatformBusiness) => (
-
-
-
- {business.name}
-
-
-
-
- {business.subdomain}.{getBaseDomain()}
-
-
-
-
- {business.tier}
-
-
-
-
- {business.owner ? business.owner.full_name : '-'}
-
- {business.owner && (
-
- {business.owner.email}
-
- )}
-
-
- {business.is_active ? (
-
-
- {t('platform.active')}
-
- ) : (
-
-
- {t('platform.inactive')}
-
- )}
-
-
- {business.owner && (
+
+ {business.owner && !business.owner.email_verified && ( // Assuming PlatformBusiness owner object has email_verified, if not we might need to fetch it or update interface
+ handleVerifyEmail(business.owner!.id)}
+ className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
+ title={t('platform.verifyEmail')}
+ >
+ {t('platform.verify')}
+
+ )}
+ {business.owner && (
+ handleLoginAs(business)}
+ className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
+ title={`Masquerade as ${business.owner.email}`}
+ >
+
+ Masquerade
+
+ )}
handleLoginAs(business)}
+ onClick={() => setEditingBusiness(business)}
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
- title={`Masquerade as ${business.owner.email}`}
+ title={t('common.edit')}
>
-
- Masquerade
+ {t('common.edit')}
- )}
- setEditingBusiness(business)}
- className="inline-flex items-center gap-1 text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300"
- title={t('common.edit')}
- >
-
-
-
-
+ >
+ }
+ />
);
if (isLoading) {
@@ -123,139 +119,78 @@ const PlatformBusinesses: React.FC = ({ onMasquerade })
return (
- {/* Header */}
-
-
-
{t('platform.businesses')}
-
{t('platform.businessesDescription')}
-
-
setShowInviteModal(true)}
- className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium shadow-sm"
- >
-
- Invite Tenant
-
-
- {/* Search Bar */}
-
-
-
- setSearchTerm(e.target.value)}
- className="w-full pl-10 pr-4 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-indigo-500 focus:border-indigo-500"
- />
-
-
-
- {t('common.filters')}
-
-
- {/* Business Table */}
-
-
-
-
-
-
- {t('platform.businessName')}
-
-
- {t('platform.subdomain')}
-
-
- {t('platform.tier')}
-
-
- {t('platform.owner')}
-
-
- {t('platform.status')}
-
-
- {t('common.actions')}
-
-
-
-
- {activeBusinesses.map(renderBusinessRow)}
-
-
-
-
- {activeBusinesses.length === 0 && inactiveBusinesses.length === 0 && (
-
-
- {searchTerm ? t('platform.noBusinessesFound') : t('platform.noBusinesses')}
-
-
- )}
-
-
- {/* Inactive Businesses Section */}
- {inactiveBusinesses.length > 0 && (
-
+
setShowInactiveBusinesses(!showInactiveBusinesses)}
- className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-gray-200 dark:hover:bg-gray-700/50 rounded-xl transition-colors"
+ onClick={() => setShowInviteModal(true)}
+ className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium shadow-sm"
>
-
- {showInactiveBusinesses ? : }
-
-
- Inactive Businesses ({inactiveBusinesses.length})
-
-
+
+ Invite Tenant
+ }
+ emptyMessage={searchTerm ? t('platform.noBusinessesFound') : t('platform.noBusinesses')}
+ extraContent={
+ inactiveBusinesses.length > 0 ? (
+
+
setShowInactiveBusinesses(!showInactiveBusinesses)}
+ className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-gray-200 dark:hover:bg-gray-700/50 rounded-xl transition-colors"
+ >
+
+ {showInactiveBusinesses ? : }
+
+
+ Inactive Businesses ({inactiveBusinesses.length})
+
+
+
- {showInactiveBusinesses && (
-
-
-
-
-
-
- {t('platform.businessName')}
-
-
- {t('platform.subdomain')}
-
-
- {t('platform.tier')}
-
-
- {t('platform.owner')}
-
-
- {t('platform.status')}
-
-
- {t('common.actions')}
-
-
-
-
- {inactiveBusinesses.map(renderBusinessRow)}
-
-
-
+ {showInactiveBusinesses && (
+
+ )}
- )}
-
- )}
+ ) : null
+ }
+ />
{/* Modals */}
setShowInviteModal(false)}
/>
- setEditingBusiness(null)}
/>
diff --git a/frontend/src/pages/platform/PlatformUsers.tsx b/frontend/src/pages/platform/PlatformUsers.tsx
index ab3cd3f..c023f52 100644
--- a/frontend/src/pages/platform/PlatformUsers.tsx
+++ b/frontend/src/pages/platform/PlatformUsers.tsx
@@ -1,8 +1,12 @@
-
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Search, Filter, Eye, Shield, User as UserIcon } from 'lucide-react';
+import { Eye, Check, Pencil } from 'lucide-react';
import { usePlatformUsers } from '../../hooks/usePlatform';
+import { verifyUserEmail, PlatformUser } from '../../api/platform';
+import { useQueryClient } from '@tanstack/react-query';
+import PlatformListing from './components/PlatformListing';
+import PlatformListRow from './components/PlatformListRow';
+import EditPlatformEntityModal from './components/EditPlatformEntityModal';
interface PlatformUsersProps {
onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
@@ -10,32 +14,34 @@ interface PlatformUsersProps {
const PlatformUsers: React.FC = ({ onMasquerade }) => {
const { t } = useTranslation();
+ const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
const { data: users, isLoading, error } = usePlatformUsers();
+ const [editingUser, setEditingUser] = useState(null);
const filteredUsers = (users || []).filter(u => {
+ const isPlatformUser = ['superuser', 'platform_manager', 'platform_sales', 'platform_support'].includes(u.role);
+ if (!isPlatformUser) return false;
+
const matchesSearch = (u.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
- u.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
- u.username.toLowerCase().includes(searchTerm.toLowerCase());
+ u.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ u.username.toLowerCase().includes(searchTerm.toLowerCase());
const matchesRole = roleFilter === 'all' || u.role === roleFilter;
return matchesSearch && matchesRole;
});
const getRoleBadgeColor = (role: string) => {
- switch(role) {
- case 'superuser': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
- case 'platform_manager':
- case 'platform_support': return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300';
- case 'owner': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
- case 'staff': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
- case 'customer': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
- default: return 'bg-gray-100 text-gray-800';
+ switch (role) {
+ case 'superuser': return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
+ case 'platform_manager': return 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300';
+ case 'platform_sales': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
+ case 'platform_support': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
+ default: return 'bg-gray-100 text-gray-800';
}
};
const handleMasquerade = (platformUser: any) => {
- // Pass user info to masquerade - we only need the id
onMasquerade({
id: platformUser.id,
username: platformUser.username,
@@ -45,111 +51,93 @@ const PlatformUsers: React.FC = ({ onMasquerade }) => {
});
};
- if (isLoading) {
- return (
-
-
{t('common.loading')}
-
- );
- }
+ const handleVerifyEmail = async (userId: number) => {
+ if (confirm(t('platform.confirmVerifyEmail'))) {
+ try {
+ await verifyUserEmail(userId);
+ queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
+ } catch (error) {
+ alert(t('errors.generic'));
+ }
+ }
+ };
- if (error) {
- return (
-
-
{t('errors.generic')}
-
- );
- }
+ const renderRow = (u: PlatformUser) => (
+
+ {u.email}
+ {u.email_verified && }
+ >
+ }
+ actions={
+ <>
+ {!u.email_verified && (
+ handleVerifyEmail(u.id)}
+ className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
+ title={t('platform.verifyEmail')}
+ >
+ {t('platform.verify')}
+
+ )}
+ handleMasquerade(u)}
+ className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
+ disabled={u.is_superuser}
+ title={u.is_superuser ? 'Cannot masquerade as superuser' : `Masquerade as ${u.name || u.username}`}
+ >
+ {t('platform.masquerade')}
+
+ setEditingUser(u)}
+ className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
+ title={t('common.edit')}
+ >
+ {t('common.edit')}
+
+ >
+ }
+ />
+ );
return (
-
-
-
-
{t('platform.userDirectory')}
-
{t('platform.userDirectoryDescription')}
-
-
-
-
-
-
- setSearchTerm(e.target.value)}
- className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:text-white"
- />
-
-
setRoleFilter(e.target.value)}
- className="px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-gray-700 dark:text-gray-200"
- >
- {t('platform.allRoles')}
- {t('platform.roles.superuser')}
- {t('platform.roles.platformManager')}
- {t('platform.roles.businessOwner')}
- {t('platform.roles.staff')}
- {t('platform.roles.customer')}
-
-
-
-
-
-
-
- {t('platform.user')}
- {t('platform.role')}
- {t('platform.email')}
- {t('common.actions')}
-
-
-
- {filteredUsers.map((u) => (
-
-
-
-
- {(u.name || u.username).charAt(0).toUpperCase()}
-
-
-
{u.name || u.username}
- {u.business_name && (
-
{u.business_name}
- )}
-
-
-
-
-
- {(u.role || 'customer').replace(/_/g, ' ')}
-
-
-
- {u.email}
-
-
- handleMasquerade(u)}
- className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors"
- disabled={u.is_superuser}
- title={u.is_superuser ? 'Cannot masquerade as superuser' : `Masquerade as ${u.name || u.username}`}
- >
- {t('platform.masquerade')}
-
-
-
- ))}
-
-
- {filteredUsers.length === 0 && (
-
- {t('platform.noUsersFound')}
-
- )}
-
-
+ <>
+
+ setEditingUser(null)}
+ />
+ >
);
};
diff --git a/frontend/src/pages/platform/components/EditPlatformEntityModal.tsx b/frontend/src/pages/platform/components/EditPlatformEntityModal.tsx
new file mode 100644
index 0000000..37971cc
--- /dev/null
+++ b/frontend/src/pages/platform/components/EditPlatformEntityModal.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { PlatformBusiness, PlatformUser } from '../../../api/platform';
+import BusinessEditModal from './BusinessEditModal';
+import EditPlatformUserModal from './EditPlatformUserModal';
+
+interface EditPlatformEntityModalProps {
+ entity: PlatformUser | PlatformBusiness | null;
+ type: 'user' | 'business';
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+const EditPlatformEntityModal: React.FC = ({
+ entity,
+ type,
+ isOpen,
+ onClose,
+}) => {
+ if (!isOpen || !entity) return null;
+
+ if (type === 'business') {
+ return (
+
+ );
+ }
+
+ if (type === 'user') {
+ // Need to cast or transform PlatformUser to the shape expected by EditPlatformUserModal
+ // EditPlatformUserModal expects: { id, username, email, first_name, last_name, role, is_active, permissions }
+ // PlatformUser has: { id, username, email, name, role, is_active, permissions, ... }
+ // We need to split name into first/last if not present
+ const user = entity as PlatformUser;
+
+ // Helper to split name if needed (though PlatformUser usually has first/last in backend, the interface might vary)
+ // Let's check PlatformUser interface in api/platform.ts
+ // It has name?: string, but serializer sends first_name, last_name.
+ // Let's assume the object passed in has them.
+
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+export default EditPlatformEntityModal;
diff --git a/frontend/src/pages/platform/components/PlatformListRow.tsx b/frontend/src/pages/platform/components/PlatformListRow.tsx
new file mode 100644
index 0000000..65b77f2
--- /dev/null
+++ b/frontend/src/pages/platform/components/PlatformListRow.tsx
@@ -0,0 +1,57 @@
+import React, { ReactNode } from 'react';
+import { Check, Eye, Pencil } from 'lucide-react';
+
+interface PlatformListRowProps {
+ avatarLetter: string;
+ primaryText: string;
+ secondaryText?: string;
+ badgeText: string;
+ badgeColor?: string;
+ tertiaryText: ReactNode;
+ actions: ReactNode;
+}
+
+const PlatformListRow: React.FC = ({
+ avatarLetter,
+ primaryText,
+ secondaryText,
+ badgeText,
+ badgeColor = 'bg-gray-100 text-gray-800',
+ tertiaryText,
+ actions,
+}) => {
+ return (
+
+
+
+
+ {avatarLetter}
+
+
+
{primaryText}
+ {secondaryText && (
+
{secondaryText}
+ )}
+
+
+
+
+
+ {badgeText}
+
+
+
+
+ {tertiaryText}
+
+
+
+
+ {actions}
+
+
+
+ );
+};
+
+export default PlatformListRow;
diff --git a/frontend/src/pages/platform/components/PlatformListing.tsx b/frontend/src/pages/platform/components/PlatformListing.tsx
new file mode 100644
index 0000000..6f0597d
--- /dev/null
+++ b/frontend/src/pages/platform/components/PlatformListing.tsx
@@ -0,0 +1,109 @@
+import React, { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Search } from 'lucide-react';
+import PlatformTable from './PlatformTable';
+
+interface PlatformListingProps {
+ title: string;
+ description: string;
+ isLoading: boolean;
+ error: any;
+ data: T[];
+ renderRow: (item: T) => ReactNode;
+ columns: string[];
+ searchPlaceholder: string;
+ searchTerm: string;
+ onSearchChange: (term: string) => void;
+ filterOptions?: { label: string; value: string }[];
+ filterValue?: string;
+ onFilterChange?: (value: string) => void;
+ actionButton?: ReactNode;
+ emptyMessage?: string;
+ extraContent?: ReactNode;
+}
+
+function PlatformListing({
+ title,
+ description,
+ isLoading,
+ error,
+ data,
+ renderRow,
+ columns,
+ searchPlaceholder,
+ searchTerm,
+ onSearchChange,
+ filterOptions,
+ filterValue,
+ onFilterChange,
+ actionButton,
+ emptyMessage,
+ extraContent,
+}: PlatformListingProps) {
+ const { t } = useTranslation();
+
+ if (isLoading) {
+ return (
+
+
{t('common.loading')}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{t('errors.generic')}
+
+ );
+ }
+
+ return (
+
+
+
+
{title}
+
{description}
+
+ {actionButton}
+
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="w-full pl-10 pr-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:text-white"
+ />
+
+ {filterOptions && onFilterChange && (
+
onFilterChange(e.target.value)}
+ className="px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 text-gray-700 dark:text-gray-200"
+ >
+ {filterOptions.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ )}
+
+
+
+
+ {extraContent}
+
+ );
+}
+
+export default PlatformListing;
diff --git a/frontend/src/pages/platform/components/PlatformTable.tsx b/frontend/src/pages/platform/components/PlatformTable.tsx
new file mode 100644
index 0000000..c5e7707
--- /dev/null
+++ b/frontend/src/pages/platform/components/PlatformTable.tsx
@@ -0,0 +1,52 @@
+import React, { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+
+interface PlatformTableProps {
+ data: T[];
+ columns: string[];
+ renderRow: (item: T) => ReactNode;
+ emptyMessage?: string;
+ className?: string;
+}
+
+function PlatformTable({
+ data,
+ columns,
+ renderRow,
+ emptyMessage,
+ className = "bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden"
+}: PlatformTableProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {columns.map((col, idx) => (
+
+ {col}
+
+ ))}
+
+
+
+ {data.map((item, idx) => (
+
+ {renderRow(item)}
+
+ ))}
+
+
+
+ {data.length === 0 && (
+
+ {emptyMessage || t('common.noResults')}
+
+ )}
+
+ );
+}
+
+export default PlatformTable;
diff --git a/smoothschedule/config/settings/base.py b/smoothschedule/config/settings/base.py
index 1ecc3d5..e0dbbde 100644
--- a/smoothschedule/config/settings/base.py
+++ b/smoothschedule/config/settings/base.py
@@ -314,7 +314,7 @@ REST_FRAMEWORK = {
}
# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup
-CORS_URLS_REGEX = r"^/api/.*$"
+CORS_URLS_REGEX = r"^/(api|auth)/.*$"
# By Default swagger ui is available only to admin user(s). You can change permission classes to change that
# See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings
diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py
index a13796b..54e6f2c 100644
--- a/smoothschedule/config/urls.py
+++ b/smoothschedule/config/urls.py
@@ -14,7 +14,8 @@ from smoothschedule.users.api_views import (
login_view, current_user_view, logout_view, send_verification_email, verify_email,
hijack_acquire_view, hijack_release_view,
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
- invitation_details_view, accept_invitation_view, decline_invitation_view
+ invitation_details_view, accept_invitation_view, decline_invitation_view,
+ check_subdomain_view, signup_view
)
from smoothschedule.users.mfa_api_views import (
mfa_status, send_phone_verification, verify_phone, enable_sms_mfa,
@@ -66,6 +67,8 @@ urlpatterns += [
path("api/auth/oauth/", include("core.oauth_urls", namespace="auth_oauth")),
# Auth API
path("api/auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"),
+ path("auth/signup/check-subdomain/", check_subdomain_view, name="check_subdomain"),
+ path("auth/signup/", signup_view, name="signup"),
path("api/auth/login/", login_view, name="login"),
path("api/auth/me/", current_user_view, name="current_user"),
path("api/auth/logout/", logout_view, name="logout"),
diff --git a/smoothschedule/docker-compose.production.yml b/smoothschedule/docker-compose.production.yml
index 789d26a..7d5204b 100644
--- a/smoothschedule/docker-compose.production.yml
+++ b/smoothschedule/docker-compose.production.yml
@@ -48,7 +48,7 @@ services:
nginx:
build:
- context: ../frontend
+ context: ../smoothschedule-frontend
dockerfile: Dockerfile.prod
depends_on:
- django
diff --git a/smoothschedule/platform_admin/serializers.py b/smoothschedule/platform_admin/serializers.py
index 1609963..3b36286 100644
--- a/smoothschedule/platform_admin/serializers.py
+++ b/smoothschedule/platform_admin/serializers.py
@@ -210,6 +210,7 @@ class TenantSerializer(serializers.ModelSerializer):
'full_name': owner.full_name,
'email': owner.email,
'role': owner.role.lower(),
+ 'email_verified': owner.email_verified,
}
except:
pass
@@ -388,7 +389,7 @@ class PlatformUserSerializer(serializers.ModelSerializer):
model = User
fields = [
'id', 'email', 'username', 'first_name', 'last_name', 'full_name', 'role',
- 'is_active', 'is_staff', 'is_superuser', 'permissions',
+ 'is_active', 'is_staff', 'is_superuser', 'email_verified', 'permissions',
'business', 'business_name', 'business_subdomain',
'date_joined', 'last_login', 'created_at'
]
diff --git a/smoothschedule/platform_admin/views.py b/smoothschedule/platform_admin/views.py
index 9ce5b6c..3becfac 100644
--- a/smoothschedule/platform_admin/views.py
+++ b/smoothschedule/platform_admin/views.py
@@ -800,6 +800,16 @@ class PlatformUserViewSet(viewsets.ModelViewSet):
return queryset
+ @action(detail=True, methods=['post'])
+ def verify_email(self, request, pk=None):
+ """Manually verify a user's email"""
+ user = self.get_object()
+ user.email_verified = True
+ user.save(update_fields=['email_verified'])
+ return Response({'status': 'email verified'})
+
+
+
def partial_update(self, request, *args, **kwargs):
"""
Update platform user.
diff --git a/smoothschedule/smoothschedule/users/api_views.py b/smoothschedule/smoothschedule/users/api_views.py
index 3d1a5aa..e3c2c1e 100644
--- a/smoothschedule/smoothschedule/users/api_views.py
+++ b/smoothschedule/smoothschedule/users/api_views.py
@@ -18,6 +18,8 @@ from .mfa_services import mfa_manager
from core.permissions import can_hijack
from rest_framework import serializers
from schedule.models import Resource, ResourceType
+from core.models import Tenant, Domain
+from django_tenants.utils import schema_context
@api_view(['POST'])
@@ -849,3 +851,120 @@ The Smooth Schedule Team
[invitation.email],
fail_silently=False,
)
+
+
+@api_view(['POST'])
+@permission_classes([AllowAny])
+def check_subdomain_view(request):
+ """
+ Check if a subdomain is available.
+ POST /api/auth/signup/check-subdomain/
+ Body: { "subdomain": "example" }
+ """
+ subdomain = request.data.get('subdomain', '').strip().lower()
+ if not subdomain:
+ return Response({"error": "Subdomain is required"}, status=status.HTTP_400_BAD_REQUEST)
+
+ # Check reserved words
+ reserved = ['www', 'api', 'admin', 'mail', 'platform', 'app', 'dashboard', 'status', 'public']
+ if subdomain in reserved:
+ return Response({"available": False, "reason": "Reserved"}, status=status.HTTP_200_OK)
+
+ # Check Tenant schema_name
+ if Tenant.objects.filter(schema_name=subdomain).exists():
+ return Response({"available": False}, status=status.HTTP_200_OK)
+
+ # Check Domain
+ if Domain.objects.filter(domain__startswith=f"{subdomain}.").exists():
+ return Response({"available": False}, status=status.HTTP_200_OK)
+
+ return Response({"available": True}, status=status.HTTP_200_OK)
+
+
+@api_view(['POST'])
+@permission_classes([AllowAny])
+def signup_view(request):
+ """
+ Sign up a new tenant and owner.
+ POST /api/auth/signup/
+ """
+ data = request.data
+
+ # 1. Validate Subdomain
+ subdomain = data.get('subdomain', '').strip().lower()
+ if not subdomain:
+ return Response({"detail": "Subdomain is required"}, status=status.HTTP_400_BAD_REQUEST)
+
+ if Tenant.objects.filter(schema_name=subdomain).exists():
+ return Response({"detail": "Subdomain already taken"}, status=status.HTTP_400_BAD_REQUEST)
+
+ # 2. Validate User
+ email = data.get('email', '').strip().lower()
+ password = data.get('password', '')
+ if User.objects.filter(email=email).exists():
+ return Response({"detail": "User with this email already exists"}, status=status.HTTP_400_BAD_REQUEST)
+
+ try:
+ with schema_context('public'):
+ # 3. Create Tenant
+ tenant = Tenant.objects.create(
+ schema_name=subdomain,
+ name=data.get('business_name', subdomain),
+ subscription_tier=data.get('tier', 'FREE'),
+ primary_color='#2563eb', # Default
+ secondary_color='#0ea5e9', # Default
+ # Address info
+ contact_email=email,
+ phone=data.get('phone', ''),
+ )
+
+ # 4. Create Domain
+ # Determine root domain based on settings or environment
+ # For dev, we use lvh.me
+ root_domain = "lvh.me"
+ # In production, this should be smoothschedule.com
+ # We can infer from request host or settings
+ if 'smoothschedule.com' in request.get_host():
+ root_domain = 'smoothschedule.com'
+
+ domain_url = f"{subdomain}.{root_domain}"
+ Domain.objects.create(
+ domain=domain_url,
+ tenant=tenant,
+ is_primary=True
+ )
+
+ # 5. Create User (Owner)
+ user = User.objects.create_user(
+ username=email.split('@')[0], # Fallback username
+ email=email,
+ password=password,
+ first_name=data.get('first_name', ''),
+ last_name=data.get('last_name', ''),
+ role=User.Role.TENANT_OWNER,
+ tenant=tenant,
+ email_verified=False, # Require verification
+ )
+
+ # 6. Generate Token
+ token = Token.objects.create(user=user)
+
+ # 7. Send Verification Email (optional, but good practice)
+ # We can reuse send_verification_email logic or call it here
+ # For now, we just return success
+
+ return Response({
+ 'access': token.key,
+ 'user': _get_user_data(user),
+ 'tenant': {
+ 'id': tenant.id,
+ 'name': tenant.name,
+ 'subdomain': subdomain,
+ 'domain': domain_url
+ }
+ }, status=status.HTTP_201_CREATED)
+
+ except Exception as e:
+ # Cleanup if failed (transaction atomic would be better but this is simple)
+ # In a real app, use atomic transaction
+ return Response({"detail": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
diff --git a/verify_signup.sh b/verify_signup.sh
new file mode 100644
index 0000000..b5cfa02
--- /dev/null
+++ b/verify_signup.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+
+BASE_URL="http://lvh.me:8000/auth/signup/"
+
+echo "1. Testing Successful Signup (testcompany11)..."
+curl -s -X POST $BASE_URL \
+ -H "Content-Type: application/json" \
+ -d '{"subdomain": "testcompany11", "business_name": "Test 11", "email": "test11@example.com", "password": "password123", "first_name": "Test", "last_name": "User"}' \
+ | grep "access" && echo "SUCCESS" || echo "FAILED"
+
+echo -e "\n2. Testing Duplicate Subdomain (testcompany11)..."
+curl -s -X POST $BASE_URL \
+ -H "Content-Type: application/json" \
+ -d '{"subdomain": "testcompany11", "business_name": "Test 11 Duplicate", "email": "test11_dup@example.com", "password": "password123", "first_name": "Test", "last_name": "User"}' \
+ | grep "detail"
+
+echo -e "\n3. Testing Duplicate Email (test11@example.com)..."
+curl -s -X POST $BASE_URL \
+ -H "Content-Type: application/json" \
+ -d '{"subdomain": "testcompany12", "business_name": "Test 12", "email": "test11@example.com", "password": "password123", "first_name": "Test", "last_name": "User"}' \
+ | grep "detail"
+
+echo -e "\n4. Testing Missing Subdomain..."
+curl -s -X POST $BASE_URL \
+ -H "Content-Type: application/json" \
+ -d '{"business_name": "Test 13", "email": "test13@example.com", "password": "password123"}' \
+ | grep "detail"