diff --git a/src/components/ui/app-header.tsx b/src/components/ui/app-header.tsx index 963aadcc..3a962161 100644 --- a/src/components/ui/app-header.tsx +++ b/src/components/ui/app-header.tsx @@ -30,6 +30,7 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { useAuth } from "@/contexts/AuthContext"; +import { BrandLogo } from "@/components/ui/brand-logo"; interface NavItem { path: string; @@ -94,13 +95,8 @@ export function AppHeader({ className }: AppHeaderProps) { >
{/* Logo and Brand */} -
-
- SM -
- - Sheet Metal Connect - +
+
{/* Navigation Links */} diff --git a/src/components/ui/brand-logo.tsx b/src/components/ui/brand-logo.tsx new file mode 100644 index 00000000..e5a7d9ca --- /dev/null +++ b/src/components/ui/brand-logo.tsx @@ -0,0 +1,110 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import { useAuth } from "@/contexts/AuthContext"; + +interface BrandLogoProps { + /** Size variant */ + size?: "sm" | "md" | "lg"; + /** Show the text name alongside the logo */ + showName?: boolean; + /** Additional className for the container */ + className?: string; + /** Override default color scheme (for headers with specific backgrounds) */ + variant?: "default" | "light" | "dark"; +} + +const sizeConfig = { + sm: { + logo: "h-8 w-8", + text: "text-sm", + fontSize: "text-xs", + }, + md: { + logo: "h-9 w-9", + text: "text-lg", + fontSize: "text-sm", + }, + lg: { + logo: "h-10 w-10", + text: "text-xl", + fontSize: "text-base", + }, +}; + +export const BrandLogo: React.FC = ({ + size = "md", + showName = true, + className, + variant = "default", +}) => { + const { t } = useTranslation(); + const { tenant } = useAuth(); + + const config = sizeConfig[size]; + + // Check if whitelabeling is enabled and tenant has custom settings + const isWhitelabeled = tenant?.whitelabel_enabled === true; + const customLogo = isWhitelabeled ? tenant?.whitelabel_logo_url : null; + const customAppName = isWhitelabeled ? tenant?.whitelabel_app_name : null; + const customPrimaryColor = isWhitelabeled ? tenant?.whitelabel_primary_color : null; + + // Determine the app name to display + const displayName = customAppName || t("app.name"); + + // Get text color based on variant + const textColorClass = variant === "light" + ? "text-white" + : variant === "dark" + ? "text-foreground" + : "text-foreground"; + + // Default logo gradient or custom primary color + const defaultGradient = "bg-gradient-to-br from-[#3a4656] to-[#0080ff]"; + const customBgStyle = customPrimaryColor + ? { backgroundColor: customPrimaryColor } + : undefined; + + return ( +
+ {/* Logo */} + {customLogo ? ( + // Custom logo from whitelabeling + {displayName} + ) : ( + // Default logo badge with initials +
+ SM +
+ )} + + {/* Brand Name */} + {showName && ( + + {displayName} + + )} +
+ ); +}; + +export default BrandLogo; diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index bbc4c052..8d000f43 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -23,8 +23,14 @@ interface TenantInfo { id: string; name: string; company_name: string | null; - plan: "free" | "pro" | "premium"; + plan: "free" | "pro" | "premium" | "enterprise"; status: "active" | "cancelled" | "suspended" | "trial"; + // Whitelabeling fields (premium feature) + whitelabel_enabled: boolean; + whitelabel_logo_url: string | null; + whitelabel_app_name: string | null; + whitelabel_primary_color: string | null; + whitelabel_favicon_url: string | null; } interface AuthContextType { diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 10152e93..7acaf233 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -2272,6 +2272,11 @@ export type Database = { trial_ends_at: string | null updated_at: string | null vat_number: string | null + whitelabel_app_name: string | null + whitelabel_enabled: boolean | null + whitelabel_favicon_url: string | null + whitelabel_logo_url: string | null + whitelabel_primary_color: string | null working_days_mask: number | null } Insert: { @@ -2313,6 +2318,11 @@ export type Database = { trial_ends_at?: string | null updated_at?: string | null vat_number?: string | null + whitelabel_app_name?: string | null + whitelabel_enabled?: boolean | null + whitelabel_favicon_url?: string | null + whitelabel_logo_url?: string | null + whitelabel_primary_color?: string | null working_days_mask?: number | null } Update: { @@ -2354,6 +2364,11 @@ export type Database = { trial_ends_at?: string | null updated_at?: string | null vat_number?: string | null + whitelabel_app_name?: string | null + whitelabel_enabled?: boolean | null + whitelabel_favicon_url?: string | null + whitelabel_logo_url?: string | null + whitelabel_primary_color?: string | null working_days_mask?: number | null } Relationships: [] @@ -2870,6 +2885,11 @@ export type Database = { name: string plan: Database["public"]["Enums"]["subscription_plan"] status: Database["public"]["Enums"]["subscription_status"] + whitelabel_enabled: boolean + whitelabel_logo_url: string | null + whitelabel_app_name: string | null + whitelabel_primary_color: string | null + whitelabel_favicon_url: string | null }[] } get_tenant_quota: { diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 1abf91cb..68ad7479 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -50,6 +50,7 @@ import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ROUTES } from "@/routes"; import { useTranslation } from "react-i18next"; import AnimatedBackground from "@/components/AnimatedBackground"; +import { BrandLogo } from "@/components/ui/brand-logo"; import SessionTrackingBar from "@/components/SessionTrackingBar"; interface AdminLayoutProps { @@ -264,14 +265,7 @@ export default function AdminLayout({ children }: AdminLayoutProps) {
{/* Logo/Brand */}
-
- - {!collapsed && ( - - Eryxon Flow - - )} -
+
{/* Navigation */} diff --git a/src/layouts/OperatorLayout.tsx b/src/layouts/OperatorLayout.tsx index fbb1dec8..6774cc03 100644 --- a/src/layouts/OperatorLayout.tsx +++ b/src/layouts/OperatorLayout.tsx @@ -33,6 +33,7 @@ import CurrentlyTimingWidget from "@/components/operator/CurrentlyTimingWidget"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { AppTour } from "@/components/onboarding"; import { ROUTES } from "@/routes"; +import { BrandLogo } from "@/components/ui/brand-logo"; import SessionTrackingBar from "@/components/SessionTrackingBar"; interface OperatorLayoutProps { @@ -68,20 +69,7 @@ export const OperatorLayout: React.FC = ({ children }) => { >
{/* Logo/Brand */} -
-
- SM -
- - {t("app.name")} - -
+ {/* Right Side Actions */}
diff --git a/src/pages/admin/OrganizationSettings.tsx b/src/pages/admin/OrganizationSettings.tsx index d1b069d9..b0f4abbb 100644 --- a/src/pages/admin/OrganizationSettings.tsx +++ b/src/pages/admin/OrganizationSettings.tsx @@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Switch } from '@/components/ui/switch'; -import { Loader2, Building2, Save, Clock } from 'lucide-react'; +import { Loader2, Building2, Save, Clock, Paintbrush, Crown, Upload, X } from 'lucide-react'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -38,8 +38,17 @@ export default function OrganizationSettings() { factory_opening_time: '07:00', factory_closing_time: '17:00', auto_stop_tracking: false, + // Whitelabeling fields (premium feature) + whitelabel_enabled: false, + whitelabel_logo_url: '', + whitelabel_app_name: '', + whitelabel_primary_color: '', + whitelabel_favicon_url: '', }); + // Check if tenant has access to whitelabeling (premium/enterprise only) + const canUseWhitelabeling = tenant && (tenant.plan === 'premium' || tenant.plan === 'enterprise'); + useEffect(() => { loadTenantDetails(); }, [tenant]); @@ -72,6 +81,12 @@ export default function OrganizationSettings() { factory_opening_time: formatTime(data.factory_opening_time) || '07:00', factory_closing_time: formatTime(data.factory_closing_time) || '17:00', auto_stop_tracking: data.auto_stop_tracking || false, + // Whitelabeling fields + whitelabel_enabled: data.whitelabel_enabled || false, + whitelabel_logo_url: data.whitelabel_logo_url || '', + whitelabel_app_name: data.whitelabel_app_name || '', + whitelabel_primary_color: data.whitelabel_primary_color || '', + whitelabel_favicon_url: data.whitelabel_favicon_url || '', }); } catch (error: any) { console.error('Error loading tenant details:', error); @@ -89,17 +104,29 @@ export default function OrganizationSettings() { setSaving(true); try { + // Build update object with base fields + const updateData: Record = { + name: formData.name, + company_name: formData.company_name, + timezone: formData.timezone, + billing_email: formData.billing_email, + factory_opening_time: formData.factory_opening_time + ':00', + factory_closing_time: formData.factory_closing_time + ':00', + auto_stop_tracking: formData.auto_stop_tracking, + }; + + // Only include whitelabeling fields for premium/enterprise plans + if (canUseWhitelabeling) { + updateData.whitelabel_enabled = formData.whitelabel_enabled; + updateData.whitelabel_logo_url = formData.whitelabel_logo_url || null; + updateData.whitelabel_app_name = formData.whitelabel_app_name || null; + updateData.whitelabel_primary_color = formData.whitelabel_primary_color || null; + updateData.whitelabel_favicon_url = formData.whitelabel_favicon_url || null; + } + const { error } = await supabase .from('tenants') - .update({ - name: formData.name, - company_name: formData.company_name, - timezone: formData.timezone, - billing_email: formData.billing_email, - factory_opening_time: formData.factory_opening_time + ':00', - factory_closing_time: formData.factory_closing_time + ':00', - auto_stop_tracking: formData.auto_stop_tracking, - }) + .update(updateData) .eq('id', tenant.id); if (error) throw error; @@ -275,6 +302,163 @@ export default function OrganizationSettings() { + {/* Whitelabeling Settings (Premium/Enterprise only) */} + + +
+
+ + Whitelabeling +
+ {!canUseWhitelabeling && ( +
+ + Premium +
+ )} +
+ + Customize the application with your own branding + +
+ + {canUseWhitelabeling ? ( + <> + {/* Enable Whitelabeling Toggle */} +
+
+ +

+ Replace the default branding with your company's identity +

+
+ setFormData({ ...formData, whitelabel_enabled: checked })} + /> +
+ + {formData.whitelabel_enabled && ( +
+ {/* Custom App Name */} +
+ + setFormData({ ...formData, whitelabel_app_name: e.target.value })} + placeholder="Your Company Name" + /> +

+ Replaces "Sheet Metal Connect" in the navigation +

+
+ + {/* Custom Logo URL */} +
+ +
+ setFormData({ ...formData, whitelabel_logo_url: e.target.value })} + placeholder="https://yourcompany.com/logo.png" + /> + {formData.whitelabel_logo_url && ( + + )} +
+

+ Recommended: 36x36px or larger, PNG or SVG format +

+ {formData.whitelabel_logo_url && ( +
+ Preview: + Logo preview { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+ )} +
+ + {/* Custom Primary Color */} +
+ +
+ setFormData({ ...formData, whitelabel_primary_color: e.target.value })} + placeholder="#1e90ff" + className="flex-1" + /> + setFormData({ ...formData, whitelabel_primary_color: e.target.value })} + className="h-10 w-14 rounded-md border cursor-pointer" + /> +
+

+ Used for the logo badge if no custom logo is set +

+
+ +
+ +
+
+ )} + + ) : ( +
+
+ +
+
+

+ Whitelabeling is available on Premium and Enterprise plans. +

+

+ Customize the application with your company logo, name, and brand colors. +

+
+ +
+ )} +
+
+ {/* Subscription Info (Read-only for now) */} {tenant && ( diff --git a/supabase/migrations/20251203000000_add_whitelabeling.sql b/supabase/migrations/20251203000000_add_whitelabeling.sql new file mode 100644 index 00000000..b458a003 --- /dev/null +++ b/supabase/migrations/20251203000000_add_whitelabeling.sql @@ -0,0 +1,109 @@ +-- Add whitelabeling fields to tenants table for premium plan +-- Premium feature: White-label (optional) - custom logo and branding + +-- Add whitelabeling columns to tenants table +ALTER TABLE public.tenants +ADD COLUMN IF NOT EXISTS whitelabel_enabled BOOLEAN DEFAULT FALSE, +ADD COLUMN IF NOT EXISTS whitelabel_logo_url TEXT, +ADD COLUMN IF NOT EXISTS whitelabel_app_name TEXT, +ADD COLUMN IF NOT EXISTS whitelabel_primary_color TEXT, +ADD COLUMN IF NOT EXISTS whitelabel_favicon_url TEXT; + +-- Add comments for documentation +COMMENT ON COLUMN public.tenants.whitelabel_enabled IS 'Whether whitelabeling is enabled for this tenant (premium feature)'; +COMMENT ON COLUMN public.tenants.whitelabel_logo_url IS 'Custom logo URL for the tenant (displayed in navigation)'; +COMMENT ON COLUMN public.tenants.whitelabel_app_name IS 'Custom application name to display instead of default'; +COMMENT ON COLUMN public.tenants.whitelabel_primary_color IS 'Custom primary brand color (hex format, e.g., #1e90ff)'; +COMMENT ON COLUMN public.tenants.whitelabel_favicon_url IS 'Custom favicon URL for the tenant'; + +-- Update the get_tenant_info function to include whitelabeling fields +CREATE OR REPLACE FUNCTION public.get_tenant_info() +RETURNS TABLE ( + id UUID, + name TEXT, + company_name TEXT, + plan subscription_plan, + status subscription_status, + whitelabel_enabled BOOLEAN, + whitelabel_logo_url TEXT, + whitelabel_app_name TEXT, + whitelabel_primary_color TEXT, + whitelabel_favicon_url TEXT +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant_id UUID; +BEGIN + -- Get the tenant ID for the current user + SELECT p.tenant_id INTO v_tenant_id + FROM profiles p + WHERE p.id = auth.uid(); + + -- Check for active tenant override (for root admins) + IF EXISTS ( + SELECT 1 FROM profiles + WHERE id = auth.uid() + AND is_root_admin = true + AND active_tenant_id IS NOT NULL + ) THEN + SELECT active_tenant_id INTO v_tenant_id + FROM profiles + WHERE id = auth.uid(); + END IF; + + RETURN QUERY + SELECT + t.id, + t.name, + t.company_name, + t.plan, + t.status, + COALESCE(t.whitelabel_enabled, false), + t.whitelabel_logo_url, + t.whitelabel_app_name, + t.whitelabel_primary_color, + t.whitelabel_favicon_url + FROM tenants t + WHERE t.id = v_tenant_id; +END; +$$; + +-- Add comment for the function +COMMENT ON FUNCTION public.get_tenant_info IS 'Returns tenant info including whitelabeling settings for the current user'; + +-- Create a function to check if tenant has premium whitelabeling access +CREATE OR REPLACE FUNCTION public.can_use_whitelabeling(p_tenant_id UUID DEFAULT NULL) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_tenant_id UUID; + v_plan subscription_plan; + v_status subscription_status; +BEGIN + -- Use provided tenant_id or get from current user + IF p_tenant_id IS NOT NULL THEN + v_tenant_id := p_tenant_id; + ELSE + SELECT tenant_id INTO v_tenant_id + FROM profiles + WHERE id = auth.uid(); + END IF; + + -- Get tenant plan and status + SELECT plan, status INTO v_plan, v_status + FROM tenants + WHERE id = v_tenant_id; + + -- Only premium and enterprise plans with active/trial status can use whitelabeling + RETURN v_plan IN ('premium', 'enterprise') + AND v_status IN ('active', 'trial'); +END; +$$; + +COMMENT ON FUNCTION public.can_use_whitelabeling IS 'Check if tenant can use premium whitelabeling features';