feat: Add photo galleries to services, resource types management, and UI improvements

Major features:
- Add drag-and-drop photo gallery to Service create/edit modals
- Add Resource Types management section to Settings (CRUD for custom types)
- Add edit icon consistency to Resources table (pencil icon in actions)
- Improve Services page with drag-to-reorder and customer preview mockup

Backend changes:
- Add photos JSONField to Service model with migration
- Add ResourceType model with category (STAFF/OTHER), description fields
- Add ResourceTypeViewSet with CRUD operations
- Add service reorder endpoint for display order

Frontend changes:
- Services page: two-column layout, drag-reorder, photo upload
- Settings page: Resource Types tab with full CRUD modal
- Resources page: Edit icon in actions column instead of row click
- Sidebar: Payments link visibility based on role and paymentsEnabled
- Update types.ts with Service.photos and ResourceTypeDefinition

Note: Removed photos from ResourceType (kept only for Service)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 01:11:53 -05:00
parent a7c756a8ec
commit b10426fbdb
52 changed files with 4259 additions and 356 deletions

View File

@@ -194,29 +194,29 @@ const AppContent: React.FC = () => {
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
};
// Not authenticated - show public routes
if (!user) {
// On root domain, show marketing site
if (isRootDomain()) {
return (
<Routes>
<Route element={<MarketingLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// On root domain, ALWAYS show marketing site (even if logged in)
// Logged-in users will see a "Go to Dashboard" link in the navbar
if (isRootDomain()) {
return (
<Routes>
<Route element={<MarketingLayout user={user} />}>
<Route path="/" element={<HomePage />} />
<Route path="/features" element={<FeaturesPage />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/signup" element={<SignupPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
// On business subdomain, show login
// Not authenticated on subdomain - show login
if (!user) {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
@@ -232,6 +232,43 @@ const AppContent: React.FC = () => {
return <ErrorScreen error={userError as Error} />;
}
// Subdomain validation for logged-in users
const currentHostname = window.location.hostname;
const isPlatformDomain = currentHostname === 'platform.lvh.me';
const currentSubdomain = currentHostname.split('.')[0];
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api';
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
const isCustomer = user.role === 'customer';
// RULE: Platform users must be on platform subdomain (not business subdomains)
if (isPlatformUser && isBusinessSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://platform.lvh.me${port}/`;
return <LoadingScreen />;
}
// RULE: Business users must be on their own business subdomain
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
// RULE: Customers must be on their business subdomain
if (isCustomer && isPlatformDomain && user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
// Handlers
const toggleTheme = () => setDarkMode((prev) => !prev);
const handleSignOut = () => {
@@ -242,22 +279,20 @@ const AppContent: React.FC = () => {
};
const handleMasquerade = (targetUser: any) => {
// Call the masquerade API with the target user's username
// Fallback to email prefix if username is not available
const username = targetUser.username || targetUser.email?.split('@')[0];
if (!username) {
console.error('Cannot masquerade: no username or email available', targetUser);
// Call the masquerade API with the target user's id
const userId = targetUser.id;
if (!userId) {
console.error('Cannot masquerade: no user id available', targetUser);
return;
}
masqueradeMutation.mutate(username);
// Ensure userId is a number
const userPk = typeof userId === 'string' ? parseInt(userId, 10) : userId;
masqueradeMutation.mutate(userPk);
};
// Helper to check access based on roles
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
// Platform users (superuser, platform_manager, platform_support)
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
if (isPlatformUser) {
return (
<Routes>
@@ -329,54 +364,16 @@ const AppContent: React.FC = () => {
return <LoadingScreen />;
}
// Check if we're on root/platform domain without proper business context
const currentHostname = window.location.hostname;
const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me';
// Business error or no business found
if (businessError || !business) {
// If user is a business owner on root domain, redirect to their business
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
// If user has a business subdomain, redirect them there
if (user.business_subdomain) {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
return <LoadingScreen />;
}
// If on root/platform and shouldn't be here, show appropriate message
if (isRootOrPlatform) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md p-6">
<h2 className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{user.business_subdomain
? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me`
: 'Your account is not associated with a business. Please contact support.'}
</p>
<div className="flex gap-4 justify-center">
{user.business_subdomain && (
<button
onClick={() => {
const port = window.location.port ? `:${window.location.port}` : '';
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
}}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Go to Business
</button>
)}
<button
onClick={handleSignOut}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Sign Out
</button>
</div>
</div>
</div>
);
}
// No business subdomain - show error
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center max-w-md p-6">