diff --git a/src/components/AboutPage.tsx b/src/components/AboutPage.tsx index d68a5d9..d256e8b 100644 --- a/src/components/AboutPage.tsx +++ b/src/components/AboutPage.tsx @@ -540,7 +540,9 @@ const AboutPage: React.FC = () => { Questions or want to learn more?

Get in touch with us diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx index 2186204..60ef345 100644 --- a/src/components/AccountSettings.tsx +++ b/src/components/AccountSettings.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { motion } from 'framer-motion'; -import { User, Lock, AlertTriangle, Check } from 'lucide-react'; +import { User, Lock, AlertTriangle, Check, Github } from 'lucide-react'; import { useAuth } from '../hooks/useAuth'; import DashboardLayout from './DashboardLayout'; import { getApiBaseUrl } from '../utils/urlUtils'; @@ -26,9 +26,36 @@ const AccountSettings: React.FC = () => { text: string; } | null>(null); + // GitHub disconnect state + const [disconnecting, setDisconnecting] = useState(false); + const [confirmDisconnect, setConfirmDisconnect] = useState(false); + const [disconnectError, setDisconnectError] = useState(''); + const apiBase = getApiBaseUrl(); const token = localStorage.getItem('accessToken'); + const handleDisconnectGitHub = async () => { + setDisconnecting(true); + setDisconnectError(''); + try { + const res = await fetch(`${apiBase}/api/auth/disconnect/github`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + if (res.ok && data.success) { + updateUser(data.user); + setConfirmDisconnect(false); + } else { + setDisconnectError(data.message ?? 'Failed to disconnect GitHub.'); + } + } catch { + setDisconnectError('Network error. Please try again.'); + } finally { + setDisconnecting(false); + } + }; + const handleSaveProfile = async (e: React.FormEvent) => { e.preventDefault(); if (!fullName.trim()) return; @@ -280,6 +307,69 @@ const AccountSettings: React.FC = () => { )} + {/* GitHub Integration Section */} +
+
+ +

+ GitHub Integration +

+
+ +
+
+ + + {user?.githubConnected ? 'Connected' : 'Not connected'} + +
+ + {user?.githubConnected && !confirmDisconnect && ( + + )} +
+ + {confirmDisconnect && ( +
+

+ Are you sure? This will unlink your GitHub account from + Refactron. +

+ {disconnectError && ( +

{disconnectError}

+ )} +
+ + +
+
+ )} +
+ {/* Danger Zone */}
diff --git a/src/components/CaseStudiesPage.tsx b/src/components/CaseStudiesPage.tsx index 163b02f..2730450 100644 --- a/src/components/CaseStudiesPage.tsx +++ b/src/components/CaseStudiesPage.tsx @@ -225,7 +225,9 @@ const CaseStudiesPage: React.FC = () => {

Book a Session diff --git a/src/components/DashboardLayout.tsx b/src/components/DashboardLayout.tsx index b58c9fd..ceb02f6 100644 --- a/src/components/DashboardLayout.tsx +++ b/src/components/DashboardLayout.tsx @@ -24,10 +24,13 @@ import { Settings, Bell, Shield, + LucideIcon, } from 'lucide-react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { getApiBaseUrl } from '../utils/urlUtils'; +// ── types ───────────────────────────────────────────────────────────────────── + interface Notification { id: string; type: string; @@ -40,15 +43,111 @@ interface DashboardLayoutProps { children: ReactNode; } +interface NavItemDef { + icon: LucideIcon; + label: string; + path: string; +} + +// ── pure helpers ────────────────────────────────────────────────────────────── + +function getInitial(fullName?: string | null, email?: string | null): string { + return (fullName ?? email ?? '?').charAt(0).toUpperCase(); +} + +function planBadgeLabel(plan?: string | null): string | null { + if (plan === 'enterprise') return 'Ent'; + if (plan === 'pro') return 'Pro'; + return null; +} + +function formatOrgName(name?: string | null): string { + if (!name) return "User's Organization"; + return `${name.charAt(0).toUpperCase()}${name.slice(1)}'s Organization`; +} + +function toOrgSlug(name?: string | null): string { + return ( + (name || 'user') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + '-organization' + ); +} + +function formatNotifTime(iso: string): string { + const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +// ── sub-components ──────────────────────────────────────────────────────────── + +function Avatar({ + initial, + badge, +}: { + initial: string; + badge?: string | null; +}) { + return ( +
+
+ {initial} +
+ {badge && ( + + {badge} + + )} +
+ ); +} + +function NavItem({ + item, + isActive, + collapsed, +}: { + item: NavItemDef; + isActive: boolean; + collapsed: boolean; +}) { + return ( + + + {!collapsed && {item.label}} + + ); +} + +// ── main component ───────────────────────────────────────────────────────────── + +const SIDEBAR_KEY = 'sidebar-collapsed'; + const DashboardLayout: React.FC = ({ children }) => { const { user, logout } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + + const [collapsed, setCollapsed] = useState( + () => localStorage.getItem(SIDEBAR_KEY) === 'true' + ); const [isOrgDropdownOpen, setIsOrgDropdownOpen] = useState(false); const [isProfileDropdownOpen, setIsProfileDropdownOpen] = useState(false); const [isNotifOpen, setIsNotifOpen] = useState(false); const [notifications, setNotifications] = useState([]); + const orgDropdownRef = useRef(null); const profileDropdownRef = useRef(null); const notifRef = useRef(null); @@ -56,6 +155,15 @@ const DashboardLayout: React.FC = ({ children }) => { const apiBase = getApiBaseUrl(); const token = localStorage.getItem('accessToken'); + const toggleCollapsed = () => { + setCollapsed(prev => { + localStorage.setItem(SIDEBAR_KEY, String(!prev)); + return !prev; + }); + }; + + // ── notifications ──────────────────────────────────────────────────────── + const fetchNotifications = useCallback(async () => { try { const res = await fetch(`${apiBase}/api/notifications`, { @@ -70,14 +178,14 @@ const DashboardLayout: React.FC = ({ children }) => { useEffect(() => { fetchNotifications(); - // Poll every 60s const interval = setInterval(fetchNotifications, 60000); return () => clearInterval(interval); }, [fetchNotifications]); + const unreadCount = notifications.filter(n => !n.read).length; + const handleOpenNotif = async () => { setIsNotifOpen(v => !v); - // Mark all read when opened if (!isNotifOpen && notifications.some(n => !n.read)) { try { await fetch(`${apiBase}/api/notifications/read-all`, { @@ -89,50 +197,25 @@ const DashboardLayout: React.FC = ({ children }) => { } }; - const unreadCount = notifications.filter(n => !n.read).length; - - const formatNotifTime = (iso: string) => { - const diff = Date.now() - new Date(iso).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - return `${Math.floor(hrs / 24)}d ago`; - }; + // ── close dropdowns on outside click ───────────────────────────────────── - // Close dropdowns when clicking outside useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - orgDropdownRef.current && - !orgDropdownRef.current.contains(event.target as Node) - ) { + const handler = (e: MouseEvent) => { + if (!orgDropdownRef.current?.contains(e.target as Node)) setIsOrgDropdownOpen(false); - } - if ( - profileDropdownRef.current && - !profileDropdownRef.current.contains(event.target as Node) - ) { + if (!profileDropdownRef.current?.contains(e.target as Node)) setIsProfileDropdownOpen(false); - } - if ( - notifRef.current && - !notifRef.current.contains(event.target as Node) - ) { - setIsNotifOpen(false); - } + if (!notifRef.current?.contains(e.target as Node)) setIsNotifOpen(false); }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); }, []); - const orgSlug = - (user?.organizationName || 'user') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') + '-organization'; + // ── nav items ───────────────────────────────────────────────────────────── - const workspaceItems = [ + const orgSlug = toOrgSlug(user?.organizationName); + + const workspaceItems: NavItemDef[] = [ { icon: Home, label: 'Home', path: `/${orgSlug}/dashboard` }, { icon: GitBranch, @@ -143,7 +226,7 @@ const DashboardLayout: React.FC = ({ children }) => { { icon: Key, label: 'API Keys', path: `/${orgSlug}/settings/api-keys` }, ]; - const accountItems = [ + const accountItems: NavItemDef[] = [ { icon: CreditCard, label: 'Billing', path: '/settings/billing' }, { icon: Settings, label: 'Account', path: '/settings/account' }, ...(user?.plan === 'enterprise' @@ -151,13 +234,7 @@ const DashboardLayout: React.FC = ({ children }) => { : []), ]; - const formatOrgName = (name: string | null | undefined) => { - if (!name) return "User's Organization"; - const capitalized = name.charAt(0).toUpperCase() + name.slice(1); - return `${capitalized}'s Organization`; - }; - - const formattedOrgName = formatOrgName(user?.organizationName); + // ── github connect ──────────────────────────────────────────────────────── const handleGitHubConnect = async () => { try { @@ -165,96 +242,110 @@ const DashboardLayout: React.FC = ({ children }) => { await initiateOAuth('github', 'connect', { redirectUri: `${window.location.origin}/auth/callback`, }); - } catch (error) { - console.error('Failed to initiate GitHub connection:', error); + } catch (err) { + console.error('Failed to initiate GitHub connection:', err); } }; + // ── derived ─────────────────────────────────────────────────────────────── + + const initial = getInitial(user?.fullName, user?.email); + const badge = planBadgeLabel(user?.plan); + const formattedOrgName = formatOrgName(user?.organizationName); + + const dropdownPopover = + 'absolute bottom-full left-0 right-0 mb-2 bg-[#0d0d0d] border border-white/[0.08] rounded-xl shadow-2xl z-50 overflow-hidden'; + const popoverMotion = { + initial: { opacity: 0, y: 10, scale: 0.95 }, + animate: { opacity: 1, y: 0, scale: 1 }, + exit: { opacity: 0, y: 10, scale: 0.95 }, + transition: { duration: 0.15, ease: 'easeOut' }, + }; + + // ── render ──────────────────────────────────────────────────────────────── + return (
{/* Sidebar */} - {/* Logo Section */} -
- {!isSidebarCollapsed && ( - + {collapsed ? ( + + ) : ( + <> + + Refactron + + Refactron + + + Beta + + + + )} -
- {/* Org Selector */} - {!isSidebarCollapsed && ( + {/* Org selector */} + {!collapsed && (
{isOrgDropdownOpen && (
- - +
- @@ -265,88 +356,62 @@ const DashboardLayout: React.FC = ({ children }) => {
)} - {/* Navigation */} -
- {/* Workspace Section */} + {/* Nav */} +
+ -
- {/* Notifications Bell */} + {/* Bottom actions */} +
+ {/* Notifications */}
- {!isSidebarCollapsed ? ( - - - Docs - - ) : ( - - - - )} - -
- {isSidebarCollapsed ? ( - - ) : ( - <> - - - - {isProfileDropdownOpen && ( - -
-
-

- Signed in as -

-

- {user?.email} + + )} + + + + {isProfileDropdownOpen && ( + +

+
+ +
+

+ {user?.fullName ?? user?.email} +

+ {user?.fullName && ( +

+ {user.email}

-
+ )} +
+
-
+
-
- -
+ -
+
- -
- - )} - - - )} + +
+ + )} +
- {/* Main Content */} + {/* Main content */}
{children}
); diff --git a/src/components/EarlyAccessForm.tsx b/src/components/EarlyAccessForm.tsx index d2f85de..55a657e 100644 --- a/src/components/EarlyAccessForm.tsx +++ b/src/components/EarlyAccessForm.tsx @@ -43,10 +43,9 @@ const EarlyAccessForm: React.FC = () => { 'your_notification_template_id'; const publicKey = process.env.REACT_APP_EMAILJS_PUBLIC_KEY || 'your_public_key'; - const fromEmail = - process.env.REACT_APP_FROM_EMAIL || 'hello@refactron.dev'; + const fromEmail = process.env.REACT_APP_FROM_EMAIL || 'om@refactron.dev'; const notificationEmail = - process.env.REACT_APP_NOTIFICATION_EMAIL || 'hello@refactron.dev'; + process.env.REACT_APP_NOTIFICATION_EMAIL || 'om@refactron.dev'; // Check if environment variables are properly set if ( @@ -299,10 +298,10 @@ const EarlyAccessForm: React.FC = () => {

Questions? Reach out to us at{' '} - hello@refactron.dev + om@refactron.dev

diff --git a/src/components/EarlyAccessModal.tsx b/src/components/EarlyAccessModal.tsx index 5ef5431..e8e1e1c 100644 --- a/src/components/EarlyAccessModal.tsx +++ b/src/components/EarlyAccessModal.tsx @@ -54,10 +54,9 @@ const EarlyAccessModal: React.FC = ({ 'your_notification_template_id'; const publicKey = process.env.REACT_APP_EMAILJS_PUBLIC_KEY || 'your_public_key'; - const fromEmail = - process.env.REACT_APP_FROM_EMAIL || 'hello@refactron.dev'; + const fromEmail = process.env.REACT_APP_FROM_EMAIL || 'om@refactron.dev'; const notificationEmail = - process.env.REACT_APP_NOTIFICATION_EMAIL || 'hello@refactron.dev'; + process.env.REACT_APP_NOTIFICATION_EMAIL || 'om@refactron.dev'; if ( serviceId === 'your_service_id' || diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 2a3e9b1..c487426 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -127,7 +127,7 @@ class ErrorBoundary extends Component {

If this problem persists, please{' '} contact our support team diff --git a/src/components/FAQSection.tsx b/src/components/FAQSection.tsx index d8edc82..6f96bb7 100644 --- a/src/components/FAQSection.tsx +++ b/src/components/FAQSection.tsx @@ -132,7 +132,9 @@ const FAQSection = () => { Have a specific question? Let's talk.

Contact Us diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index a948d6f..18175cf 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -167,7 +167,9 @@ const Footer: React.FC = () => {
  • Contact diff --git a/src/components/OAuthCallback.tsx b/src/components/OAuthCallback.tsx index 069d995..f02c00d 100644 --- a/src/components/OAuthCallback.tsx +++ b/src/components/OAuthCallback.tsx @@ -12,7 +12,7 @@ import { useAuth } from '../hooks/useAuth'; */ const OAuthCallback: React.FC = () => { const navigate = useNavigate(); - const { login } = useAuth(); + const { login, updateUser } = useAuth(); const [searchParams] = useSearchParams(); const [status, setStatus] = useState<'loading' | 'success' | 'error'>( 'loading' @@ -66,8 +66,13 @@ const OAuthCallback: React.FC = () => { }); if (result.success && result.data) { - // Update auth state (hydrates from /api/auth/me for full user fields) - await login(result.data.accessToken, result.data.user); + if (result.isConnect) { + // Connect flow — keep existing session, just refresh user state + updateUser(result.data.user); + } else { + // Login / signup flow — replace session with new tokens + await login(result.data.accessToken, result.data.user); + } setStatus('success'); @@ -107,7 +112,7 @@ const OAuthCallback: React.FC = () => { return () => { mountedRef.current = false; }; - }, [searchParams, navigate, login]); + }, [searchParams, navigate, login, updateUser]); return (
    diff --git a/src/components/PricingSection.tsx b/src/components/PricingSection.tsx index 5b9975d..7c55ecc 100644 --- a/src/components/PricingSection.tsx +++ b/src/components/PricingSection.tsx @@ -51,7 +51,7 @@ const PricingSection = () => { ], cta: 'Talk to us', highlight: false, - action: () => (window.location.href = 'mailto:hello@refactron.dev'), + action: () => (window.location.href = 'mailto:om@refactron.dev'), }, ]; diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 1bd4eac..f90edc8 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -206,7 +206,12 @@ export const handleOAuthCallback = async ( code: string, state: string, config: OAuthConfig -): Promise<{ success: boolean; error?: string; data?: any }> => { +): Promise<{ + success: boolean; + error?: string; + data?: any; + isConnect?: boolean; +}> => { try { // Validate state const stateData = validateOAuthState(state); @@ -220,8 +225,46 @@ export const handleOAuthCallback = async ( const { provider, type } = stateData; const apiBaseUrl = config.apiBaseUrl || process.env.REACT_APP_API_BASE_URL || ''; + const redirectUri = + config.redirectUri || `${window.location.origin}/auth/callback`; - // Exchange code for token via backend API + // Connect flow — use dedicated authenticated endpoint, preserve existing session + if (type === 'connect') { + const token = localStorage.getItem('accessToken'); + if (!token) { + return { + success: false, + error: 'You must be logged in to connect a GitHub account.', + }; + } + + const response = await fetch( + `${apiBaseUrl}/api/auth/connect/${provider}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + credentials: 'include', + body: JSON.stringify({ code, redirectUri }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + return { + success: false, + error: data.message || `Failed to connect ${provider} account.`, + }; + } + + // Return isConnect flag so OAuthCallback knows not to replace the session + return { success: true, data, isConnect: true }; + } + + // Login / signup flow — exchange code for tokens as normal const response = await fetch( `${apiBaseUrl}/api/auth/callback/${provider}`, { @@ -230,13 +273,7 @@ export const handleOAuthCallback = async ( 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify({ - code, - state, - type, // 'login' or 'signup' - redirectUri: - config.redirectUri || `${window.location.origin}/auth/callback`, - }), + body: JSON.stringify({ code, state, type, redirectUri }), } );