Created
March 29, 2025 13:37
-
-
Save xerudro/89a98638113041a5b4062311fbac2dcd to your computer and use it in GitHub Desktop.
Profile.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/pages/Dashboard.tsx | |
import React from 'react'; | |
import { Routes, Route, Navigate } from 'react-router-dom'; | |
// --- Corrected Import Path --- | |
import { useAuth } from '../components/AuthContext'; | |
// --- Layout and Page Components --- | |
import DashboardLayout from '../components/dashboard/DashboardLayout'; | |
import DashboardOverview from '../components/dashboard/DashboardOverview'; | |
import HostingServices from '../components/dashboard/services/HostingServices'; | |
import DatabaseServices from '../components/dashboard/services/DatabaseServices'; | |
import DomainServices from '../components/dashboard/services/DomainServices'; | |
import EmailServices from '../components/dashboard/services/EmailServices'; | |
import SSLServices from '../components/dashboard/services/SSLServices'; | |
// --- REMOVED: Incorrect component for user dashboard --- | |
// import CurrencyServices from '../components/dashboard/services/CurrencyServices'; | |
// --- Optionally import the user-facing converter --- | |
// import CurrencyConverter from '../components/CurrencyConverter'; | |
// Import other dashboard pages as needed (Billing, Support, Settings) | |
const Dashboard = () => { | |
// Get user and loading state | |
const { user, isLoadingAuth } = useAuth(); | |
// **RECOMMENDATION:** Move this logic to a <ProtectedRoute> component in App.tsx | |
// ProtectedRoute should handle isLoadingAuth and redirect to '/login' if !user | |
if (!isLoadingAuth && !user) { | |
console.log("Dashboard: User not authenticated, redirecting..."); | |
// Redirect to login page is usually preferred for protected areas | |
return <Navigate to="/login" replace />; | |
} | |
// Optional: Show a loading indicator while initial auth check is running | |
// This prevents rendering the layout before knowing the auth status. | |
// If using ProtectedRoute, this check might not be needed here. | |
if (isLoadingAuth) { | |
return ( | |
<div className="flex justify-center items-center min-h-screen bg-black"> | |
{/* Add a suitable loading spinner */} | |
<div className="text-white">Loading Dashboard...</div> | |
</div> | |
); | |
} | |
// End of recommended changes related to auth check | |
return ( | |
<DashboardLayout> | |
<Routes> | |
{/* Default dashboard route */} | |
<Route path="/" element={<DashboardOverview />} /> | |
{/* Service-specific routes */} | |
<Route path="hosting" element={<HostingServices />} /> | |
<Route path="databases" element={<DatabaseServices />} /> | |
<Route path="domains" element={<DomainServices />} /> | |
<Route path="email" element={<EmailServices />} /> | |
<Route path="ssl" element={<SSLServices />} /> | |
{/* --- REMOVED: Incorrect route for user dashboard --- */} | |
{/* <Route path="currency" element={<CurrencyServices />} /> */} | |
{/* --- Optional: Add route for user currency converter --- */} | |
{/* <Route path="currency-converter" element={<CurrencyConverter />} /> */} | |
{/* TODO: Add routes for Billing, Support, Settings */} | |
{/* <Route path="billing" element={<BillingPage />} /> */} | |
{/* <Route path="support" element={<SupportPage />} /> */} | |
{/* <Route path="settings" element={<SettingsPage />} /> */} | |
{/* Optional: Catch-all for unknown dashboard routes */} | |
<Route path="*" element={<Navigate to="/dashboard" replace />} /> | |
</Routes> | |
</DashboardLayout> | |
); | |
}; | |
export default Dashboard; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/pages/Home.tsx | |
import React from 'react'; | |
import Hero from '../components/Hero'; | |
import Card from '../components/Card'; | |
import CardGrid from '../components/CardGrid'; | |
import { Server, Globe, Code, Shield, Cpu, Cloud, Database, Lock } from 'lucide-react'; | |
const Home = () => { | |
// Feature data (unchanged) | |
const features = [ | |
{ icon: <Server className="h-8 w-8 text-orangered" />, title: "High-Performance Hosting", description: "Lightning-fast servers with 99.9% uptime guarantee." }, | |
{ icon: <Globe className="h-8 w-8 text-orangered" />, title: "Global CDN", description: "Content delivery network ensuring fast access worldwide." }, | |
{ icon: <Code className="h-8 w-8 text-orangered" />, title: "Custom Development", description: "Tailored solutions built with cutting-edge technologies." }, | |
{ icon: <Shield className="h-8 w-8 text-orangered" />, title: "Advanced Security", description: "Enterprise-grade security measures to protect your assets." }, | |
{ icon: <Cpu className="h-8 w-8 text-orangered" />, title: "Scalable Infrastructure", description: "Grow your applications with our flexible infrastructure." }, | |
{ icon: <Cloud className="h-8 w-8 text-orangered" />, title: "Cloud Solutions", description: "Leverage the power of cloud computing with managed services." }, | |
{ icon: <Database className="h-8 w-8 text-orangered" />, title: "Database Management", description: "Reliable and secure database solutions for your needs." }, | |
{ icon: <Lock className="h-8 w-8 text-orangered" />, title: "SSL Security", description: "Keep your data safe with industry-standard encryption." } | |
]; | |
// Pricing plans data - Added actionLink | |
const plans = [ | |
{ | |
title: "Starter", | |
description: "Perfect for personal sites.", // Added description for consistency with Card component | |
price: { currency: "$", amount: "29", period: "mo" }, | |
features: ["1 Website", "10GB Storage", "Free SSL", "24/7 Support", "Basic Analytics"], | |
popular: false, | |
actionLink: "/signup?plan=starter" // Link to signup with plan info | |
}, | |
{ | |
title: "Professional", | |
description: "Ideal for growing businesses.", // Added description | |
price: { currency: "$", amount: "79", period: "mo" }, | |
features: ["5 Websites", "50GB Storage", "Free SSL", "Priority Support", "Advanced Analytics", "Free Domain"], | |
popular: true, | |
actionLink: "/signup?plan=professional" // Link to signup with plan info | |
}, | |
{ | |
title: "Enterprise", | |
description: "Scalable enterprise solutions.", // Added description | |
price: { currency: "$", amount: "199", period: "mo" }, | |
features: ["Unlimited Websites", "500GB Storage", "Free SSL", "VIP Support", "Advanced Analytics", "Free Domain", "White Label"], | |
popular: false, | |
actionLink: "/signup?plan=enterprise" // Link to signup with plan info | |
} | |
]; | |
return ( | |
<main> | |
{/* Hero Section */} | |
<Hero /> | |
{/* Features Section */} | |
<section id="home-features" aria-labelledby="features-heading" className="bg-black py-24 sm:py-32"> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
<div className="text-center mb-16"> | |
<h2 id="features-heading" className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-4"> | |
Why Choose <span className="text-orangered">Us</span> | |
</h2> | |
<p className="text-lg text-gray-400 max-w-3xl mx-auto"> | |
Experience the perfect blend of innovation, performance, and reliability. | |
</p> | |
</div> | |
{/* Use CardGrid and Card for features */} | |
<CardGrid columns={{ sm: 1, md: 2, lg: 4 }} gap={8}> | |
{features.map((feature, index) => ( | |
<Card | |
key={index} | |
variant="feature" // Explicitly set variant | |
icon={feature.icon} | |
title={feature.title} | |
description={feature.description} | |
/> | |
))} | |
</CardGrid> | |
</div> | |
</section> | |
{/* Pricing Section */} | |
<section id="home-pricing" aria-labelledby="pricing-heading" className="bg-gradient-to-b from-black via-gray-900 to-black py-24 sm:py-32"> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
<div className="text-center mb-16"> | |
<h2 id="pricing-heading" className="text-3xl sm:text-4xl lg:text-5xl font-bold text-white mb-4"> | |
Choose Your <span className="text-orangered">Plan</span> | |
</h2> | |
<p className="text-lg text-gray-400 max-w-3xl mx-auto"> | |
Select the perfect plan for your needs with our flexible pricing options. | |
</p> | |
</div> | |
{/* Use CardGrid and Card for pricing */} | |
<CardGrid columns={{ sm: 1, md: 2, lg: 3 }} gap={8}> | |
{plans.map((plan, index) => ( | |
<Card | |
key={index} | |
{...plan} // Spreads title, description, price, features, popular, actionLink | |
variant="pricing" | |
actionLabel="Get Started" // Label for the button/link | |
// actionLink is now passed via {...plan} | |
/> | |
))} | |
</CardGrid> | |
</div> | |
</section> | |
{/* Optional: Add other sections like Testimonials, Contact CTA, etc. */} | |
</main> | |
); | |
}; | |
export default Home; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/pages/Profile.tsx | |
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; | |
import { useNavigate, Link } from 'react-router-dom'; // Import Link | |
import { User, Mail, Phone, MapPin, Lock, Upload, Edit2, Check, X, Loader2, Server, Globe /* Add other service icons */ } from 'lucide-react'; | |
import { toast } from 'react-hot-toast'; | |
import { profileSchema, passwordSchema } from '../lib/validation'; // Assuming these are okay | |
import type { z } from 'zod'; | |
// Corrected import path | |
import { useAuth } from '../components/AuthContext'; | |
// Assuming ConfirmationModal exists | |
// import { ConfirmationModal } from '../components/shared/ConfirmationModal'; | |
// --- Define Types (Example) --- | |
interface UserProfileData { | |
name: string; | |
email: string; // Usually not editable directly here | |
phone: string | null; | |
address: string | null; | |
avatarUrl: string | null; | |
} | |
interface UserServiceData { | |
id: string; | |
name: string; | |
type: 'hosting' | 'vps' | 'domain' | string; // Allow other types | |
status: 'active' | 'pending' | 'expired' | string; | |
expiryDate: string | null; // ISO Date string or null | |
price: number | null; | |
manageLink?: string; // Optional link provided by API | |
} | |
interface ApiError { message: string; } | |
type ProfileFormData = z.infer<typeof profileSchema>; | |
type PasswordFormData = z.infer<typeof passwordSchema>; | |
// --- Component --- | |
const Profile: React.FC = () => { | |
const navigate = useNavigate(); | |
const { user, isLoadingAuth, setUser: setAuthUser } = useAuth(); // Get setUser to update context if needed | |
// UI State | |
const [isEditing, setIsEditing] = useState(false); | |
const [showPasswordChange, setShowPasswordChange] = useState(false); | |
// Data State | |
const [userProfile, setUserProfile] = useState<UserProfileData | null>(null); | |
const [services, setServices] = useState<UserServiceData[]>([]); | |
// Form State | |
const [profileForm, setProfileForm] = useState<Partial<ProfileFormData>>({}); // Use partial for editing form | |
const [passwordForm, setPasswordForm] = useState<PasswordFormData>({ currentPassword: '', newPassword: '', confirmPassword: '' }); | |
// Loading/Error State | |
const [isLoadingProfile, setIsLoadingProfile] = useState(true); | |
const [isLoadingServices, setIsLoadingServices] = useState(true); | |
const [isSubmittingProfile, setIsSubmittingProfile] = useState(false); | |
const [isSubmittingPassword, setIsSubmittingPassword] = useState(false); | |
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); | |
const [profileError, setProfileError] = useState<string | null>(null); | |
const [servicesError, setServicesError] = useState<string | null>(null); | |
const [formErrors, setFormErrors] = useState<Record<string, string>>({}); | |
const avatarInputRef = useRef<HTMLInputElement>(null); | |
// --- Get API Base URL & Token --- | |
const API_BASE_URL = useMemo(() => { /* ... copy ... */ return "/api/v1"; }, []); | |
const token = useMemo(() => localStorage.getItem('authToken'), []); | |
// --- API Call Helper --- | |
const callApi = useCallback(async <T,>(/* ... copy helper ... */ | |
endpoint: string, method: string, body?: object | FormData | null, isFormData = false | |
): Promise<T> => { | |
if (!token) throw new Error('Authentication token not found.'); | |
const headers: HeadersInit = { 'Authorization': `Bearer ${token}` }; | |
if (!isFormData && body) headers['Content-Type'] = 'application/json'; | |
const config: RequestInit = { method, headers, body: isFormData ? (body as FormData) : (body ? JSON.stringify(body) : null) }; | |
const response = await fetch(`${API_BASE_URL}${endpoint}`, config); | |
const data = await response.json(); | |
if (!response.ok) throw new Error((data as ApiError)?.message || `API Error: ${response.status}`); | |
return data as T; | |
}, [API_BASE_URL, token]); | |
// --- Fetch Initial Data --- | |
const loadData = useCallback(async () => { | |
if (!user || !token) { // Ensure user and token are available | |
setIsLoadingProfile(false); setIsLoadingServices(false); | |
return; | |
} | |
setIsLoadingProfile(true); setIsLoadingServices(true); | |
setProfileError(null); setServicesError(null); | |
try { | |
const [profileData, servicesData] = await Promise.all([ | |
callApi<UserProfileData>('/users/profile', 'GET'), // GET /api/users/profile | |
callApi<UserServiceData[]>('/users/services', 'GET') // GET /api/users/services | |
]); | |
setUserProfile(profileData); | |
setProfileForm({ // Initialize form with fetched data | |
name: profileData?.name || '', | |
email: profileData?.email || '', // Keep email display consistent | |
phone: profileData?.phone || '', | |
address: profileData?.address || '', | |
avatar: profileData?.avatarUrl || undefined // Zod schema uses avatar? Let's assume it means avatarUrl | |
}); | |
setServices(servicesData || []); | |
} catch (err: any) { | |
console.error('Failed to load profile/services:', err); | |
setProfileError(err.message || 'Failed to load profile data.'); | |
setServicesError(err.message || 'Failed to load services.'); | |
} finally { | |
setIsLoadingProfile(false); | |
setIsLoadingServices(false); | |
} | |
}, [callApi, user, token]); | |
useEffect(() => { | |
// Only load data once auth is confirmed and user exists | |
if (!isLoadingAuth && user) { | |
loadData(); | |
} else if (!isLoadingAuth && !user) { | |
// Redirect handled by ProtectedRoute, but clear loading state | |
setIsLoadingProfile(false); | |
setIsLoadingServices(false); | |
} | |
}, [isLoadingAuth, user, loadData]); | |
// --- Event Handlers --- | |
const handleFormChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
const { name, value } = e.target; | |
setProfileForm(prev => ({ ...prev, [name]: value })); | |
if (formErrors[name]) setFormErrors(prev => ({ ...prev, [name]: '' })); // Clear error on change | |
}; | |
const handlePasswordFormChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
const { name, value } = e.target; | |
setPasswordForm(prev => ({ ...prev, [name]: value })); | |
if (formErrors[name]) setFormErrors(prev => ({ ...prev, [name]: '' })); | |
}; | |
const handleAvatarChange = async (event: React.ChangeEvent<HTMLInputElement>) => { | |
const file = event.target.files?.[0]; | |
if (!file || !user) return; | |
if (file.size > 5 * 1024 * 1024) return toast.error('Image size must be less than 5MB'); | |
const formData = new FormData(); | |
formData.append('avatar', file); | |
setIsUploadingAvatar(true); setProfileError(null); | |
try { | |
// POST /api/users/profile/avatar | |
const result = await callApi<{ avatarUrl: string }>('/users/profile/avatar', 'POST', formData, true); | |
// Update local profile state and potentially the global user state in AuthContext | |
setUserProfile(prev => prev ? { ...prev, avatarUrl: result.avatarUrl } : null); | |
setAuthUser(prev => prev ? { ...prev, avatarUrl: result.avatarUrl } : null); // Update global state if needed | |
toast.success('Profile picture updated.'); | |
} catch (err: any) { | |
toast.error(err.message || 'Failed to upload avatar.'); | |
setProfileError(err.message); | |
} finally { | |
setIsUploadingAvatar(false); | |
if (avatarInputRef.current) avatarInputRef.current.value = ''; // Clear file input | |
} | |
}; | |
const handleProfileUpdate = async () => { | |
setIsSubmittingProfile(true); setFormErrors({}); setProfileError(null); | |
try { | |
// Validate only the fields being updated (name, phone, address) | |
const dataToValidate = { | |
name: profileForm.name, | |
email: userProfile?.email, // Keep original email for validation if needed by schema | |
phone: profileForm.phone, | |
address: profileForm.address | |
}; | |
const validatedData = profileSchema.parse(dataToValidate); | |
// PUT /api/users/profile | |
const updatedProfile = await callApi<UserProfileData>('/users/profile', 'PUT', { | |
name: validatedData.name, | |
phone: validatedData.phone, | |
address: validatedData.address, | |
// Don't send email or avatar here unless API supports it | |
}); | |
setUserProfile(updatedProfile); // Update local state with response from API | |
setAuthUser(prev => prev ? { ...prev, name: updatedProfile.name } : null); // Update global name | |
setIsEditing(false); | |
toast.success('Profile updated successfully.'); | |
} catch (error: any) { | |
if (error instanceof z.ZodError) { | |
const newErrors: Record<string, string> = {}; | |
error.errors.forEach(err => { if (err.path) newErrors[err.path[0]] = err.message; }); | |
setFormErrors(newErrors); | |
toast.error("Please fix the errors in the form."); | |
} else { | |
toast.error(error.message || 'Failed to update profile.'); | |
setProfileError(error.message); | |
} | |
} finally { | |
setIsSubmittingProfile(false); | |
} | |
}; | |
const handlePasswordChange = async (event: React.FormEvent) => { | |
event.preventDefault(); | |
setIsSubmittingPassword(true); setFormErrors({}); setProfileError(null); | |
try { | |
const validatedData = passwordSchema.parse(passwordForm); | |
// PUT /api/users/password | |
await callApi('/users/password', 'PUT', { | |
currentPassword: validatedData.currentPassword, | |
newPassword: validatedData.newPassword, | |
}); | |
setShowPasswordChange(false); | |
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); | |
toast.success('Password updated successfully.'); | |
} catch (error: any) { | |
if (error instanceof z.ZodError) { | |
const newErrors: Record<string, string> = {}; | |
error.errors.forEach(err => { if (err.path) newErrors[err.path[0]] = err.message; }); | |
setFormErrors(newErrors); | |
toast.error("Please fix the errors in the form."); | |
} else { | |
toast.error(error.message || 'Failed to update password.'); | |
setProfileError(error.message); // Show error related to password change | |
} | |
} finally { | |
setIsSubmittingPassword(false); | |
} | |
}; | |
// --- Helpers --- | |
const getStatusColor = (status: UserServiceData['status']) => { /* ... copy ... */ | |
switch (status) { | |
case 'active': return 'bg-green-500'; | |
default: return 'bg-gray-500'; | |
} | |
}; | |
const formatDate = (dateString: string | null) => { /* ... copy ... */ | |
if (!dateString) return '-'; | |
try { return new Date(dateString).toLocaleDateString(); } catch(e) { return dateString; } | |
}; | |
// --- Render Loading/Initial State --- | |
if (isLoadingAuth || isLoadingProfile) { | |
return <div className="flex justify-center items-center min-h-screen bg-black"><Loader2 className="w-10 h-10 animate-spin text-gray-500" /></div>; | |
} | |
// If ProtectedRoute isn't used, this might be needed, but ideally ProtectedRoute handles it | |
if (!user || !userProfile) { | |
return <div className="text-center text-gray-500 py-10">Could not load profile data or user not logged in.</div>; | |
} | |
// --- Render Profile Page --- | |
return ( | |
// Removed pt-16, assuming DashboardLayout handles padding if Profile is used there | |
// If Profile is a standalone page, add pt-16 back | |
<div className="min-h-screen bg-gradient-to-b from-black via-gray-900 to-black text-white"> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16"> | |
{/* Profile Error Display */} | |
{profileError && ( | |
<div className="mb-6 bg-red-600/10 border border-red-600/30 text-red-400 px-4 py-3 rounded-lg text-sm"> | |
Profile Error: {profileError} | |
</div> | |
)} | |
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> | |
{/* Profile Card */} | |
<div className="lg:col-span-1"> | |
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl p-6 sm:p-8 shadow-lg border border-gray-800/50"> | |
{/* Avatar */} | |
<div className="w-24 h-24 sm:w-32 sm:h-32 mx-auto relative mb-6"> | |
<img | |
src={userProfile.avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(userProfile.name || userProfile.email)}&background=random`} // Fallback avatar | |
alt="Profile" | |
className="w-full h-full rounded-full object-cover ring-2 ring-gray-700" | |
/> | |
<label className="absolute bottom-0 right-0 bg-orangered hover:bg-red-600 text-white p-2 rounded-full cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 focus-within:ring-orangered"> | |
{isUploadingAvatar ? ( | |
<Loader2 className="h-4 w-4 animate-spin" /> | |
) : ( | |
<Upload className="h-4 w-4" /> | |
)} | |
<input ref={avatarInputRef} type="file" className="hidden" accept="image/*" onChange={handleAvatarChange} disabled={isUploadingAvatar || isSubmittingProfile} /> | |
</label> | |
</div> | |
{/* Profile Info / Form */} | |
<div className="space-y-4"> | |
{isEditing ? ( | |
/* Edit Form */ | |
<> | |
{/* Name */} | |
<div> | |
<label htmlFor="name" className="block text-xs font-medium text-gray-400 mb-1">Full Name</label> | |
<input id="name" name="name" type="text" value={profileForm.name || ''} onChange={handleFormChange} disabled={isSubmittingProfile} | |
className={`w-full bg-gray-800 border ${formErrors.name ? 'border-red-500' : 'border-gray-700'} rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-orangered`} /> | |
{formErrors.name && <p className="mt-1 text-xs text-red-500">{formErrors.name}</p>} | |
</div> | |
{/* Email (Display Only) */} | |
<div> | |
<label className="block text-xs font-medium text-gray-400 mb-1">Email</label> | |
<p className="text-sm text-gray-300 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2">{userProfile.email}</p> | |
</div> | |
{/* Phone */} | |
<div> | |
<label htmlFor="phone" className="block text-xs font-medium text-gray-400 mb-1">Phone</label> | |
<input id="phone" name="phone" type="tel" value={profileForm.phone || ''} onChange={handleFormChange} disabled={isSubmittingProfile} | |
className={`w-full bg-gray-800 border ${formErrors.phone ? 'border-red-500' : 'border-gray-700'} rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-orangered`} /> | |
{formErrors.phone && <p className="mt-1 text-xs text-red-500">{formErrors.phone}</p>} | |
</div> | |
{/* Address */} | |
<div> | |
<label htmlFor="address" className="block text-xs font-medium text-gray-400 mb-1">Address</label> | |
<textarea id="address" name="address" value={profileForm.address || ''} onChange={handleFormChange} disabled={isSubmittingProfile} rows={3} | |
className={`w-full bg-gray-800 border ${formErrors.address ? 'border-red-500' : 'border-gray-700'} rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-orangered`} /> | |
{formErrors.address && <p className="mt-1 text-xs text-red-500">{formErrors.address}</p>} | |
</div> | |
{/* Action Buttons */} | |
<div className="flex space-x-3 pt-2"> | |
<button onClick={handleProfileUpdate} disabled={isSubmittingProfile || isUploadingAvatar} | |
className="flex-1 bg-orangered hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center text-sm font-medium disabled:opacity-50"> | |
{isSubmittingProfile ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Check className="h-4 w-4 mr-1.5" /> Save</>} | |
</button> | |
<button onClick={() => { setIsEditing(false); setFormErrors({}); setProfileForm(userProfile || {}); }} disabled={isSubmittingProfile || isUploadingAvatar} | |
className="flex-1 bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center text-sm font-medium disabled:opacity-50"> | |
<X className="h-4 w-4 mr-1.5" /> Cancel | |
</button> | |
</div> | |
</> | |
) : ( | |
/* Display Mode */ | |
<> | |
<div className="flex items-center text-gray-300 text-sm"><User className="h-4 w-4 text-gray-500 mr-3 flex-shrink-0" /> {userProfile.name}</div> | |
<div className="flex items-center text-gray-300 text-sm"><Mail className="h-4 w-4 text-gray-500 mr-3 flex-shrink-0" /> {userProfile.email}</div> | |
<div className="flex items-center text-gray-300 text-sm"><Phone className="h-4 w-4 text-gray-500 mr-3 flex-shrink-0" /> {userProfile.phone || <span className="text-gray-500 italic">Not set</span>}</div> | |
<div className="flex items-center text-gray-300 text-sm"><MapPin className="h-4 w-4 text-gray-500 mr-3 flex-shrink-0" /> {userProfile.address || <span className="text-gray-500 italic">Not set</span>}</div> | |
<button onClick={() => { setIsEditing(true); setProfileForm(userProfile); setFormErrors({}); }} disabled={isUploadingAvatar} | |
className="w-full mt-4 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center text-sm font-medium disabled:opacity-50"> | |
<Edit2 className="h-4 w-4 mr-1.5" /> Edit Profile | |
</button> | |
</> | |
)} | |
{/* Change Password Button */} | |
<button onClick={() => { setShowPasswordChange(true); setFormErrors({}); }} disabled={isSubmittingProfile || isUploadingAvatar} | |
className="w-full mt-3 bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center text-sm font-medium disabled:opacity-50"> | |
<Lock className="h-4 w-4 mr-1.5" /> Change Password | |
</button> | |
</div> | |
</div> | |
</div> | |
{/* Services Section */} | |
<div className="lg:col-span-2"> | |
<div className="bg-gray-900/50 backdrop-blur-sm rounded-xl p-6 sm:p-8 shadow-lg border border-gray-800/50"> | |
<h2 className="text-xl sm:text-2xl font-bold text-white mb-6">Your Services</h2> | |
{/* Services Error Display */} | |
{servicesError && ( | |
<div className="mb-4 bg-red-600/10 border border-red-600/30 text-red-400 px-4 py-3 rounded-lg text-sm"> | |
Services Error: {servicesError} | |
</div> | |
)} | |
{/* Services Loading */} | |
{isLoadingServices && ( | |
<div className="flex justify-center items-center h-20"><Loader2 className="w-6 h-6 animate-spin text-gray-500" /></div> | |
)} | |
{/* Services Empty */} | |
{!isLoadingServices && services.length === 0 && !servicesError && ( | |
<p className="text-gray-500 text-center py-4">You have no active services.</p> | |
)} | |
{/* Services List */} | |
{!isLoadingServices && services.length > 0 && ( | |
<div className="space-y-4"> | |
{services.map((service) => ( | |
<div key={service.id} className="bg-gray-800/50 rounded-lg p-4 sm:p-5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3"> | |
{/* Service Info */} | |
<div className="flex-1"> | |
<div className="flex items-center gap-2"> | |
<span className="text-base sm:text-lg font-semibold text-white">{service.name}</span> | |
<span className={`px-2 py-0.5 rounded-full text-xs font-medium text-white ${getStatusColor(service.status)}`}> | |
{service.status} | |
</span> | |
</div> | |
<div className="mt-1 text-xs sm:text-sm text-gray-400"> | |
Type: {service.type.toUpperCase()} {service.expiryDate && `• Expires: ${formatDate(service.expiryDate)}`} | |
</div> | |
</div> | |
{/* Actions */} | |
<div className="flex items-center space-x-2 sm:space-x-3 mt-2 sm:mt-0 flex-shrink-0"> | |
{service.price !== null && ( | |
<span className="text-lg font-semibold text-white"> | |
€{service.price.toFixed(2)}<span className="text-xs text-gray-400">/mo</span> | |
</span> | |
)} | |
{/* Use Link if manageLink exists, otherwise button (or hide) */} | |
{service.manageLink ? ( | |
<Link to={service.manageLink} className="text-sm bg-orangered hover:bg-red-600 text-white px-4 py-1.5 rounded-md transition-colors">Manage</Link> | |
) : ( | |
<button className="text-sm bg-orangered hover:bg-red-600 text-white px-4 py-1.5 rounded-md transition-colors">Manage</button> // Placeholder | |
)} | |
</div> | |
</div> | |
))} | |
</div> | |
)} | |
</div> | |
</div> | |
</div> | |
{/* Password Change Modal */} | |
{showPasswordChange && ( | |
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4"> | |
<div className="bg-gray-900 rounded-xl shadow-xl p-6 sm:p-8 max-w-md w-full relative"> | |
<button onClick={() => { setShowPasswordChange(false); setFormErrors({}); setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); }} disabled={isSubmittingPassword} | |
className="absolute top-3 right-3 text-gray-500 hover:text-white transition-colors disabled:opacity-50"> | |
<X className="h-6 w-6" /> | |
</button> | |
<h3 className="text-xl sm:text-2xl font-bold text-white mb-6 text-center">Change Password</h3> | |
{/* Password Form */} | |
<form onSubmit={handlePasswordChange} className="space-y-4"> | |
<div> | |
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-400 mb-1">Current Password</label> | |
<input id="currentPassword" name="currentPassword" type="password" value={passwordForm.currentPassword} onChange={handlePasswordFormChange} disabled={isSubmittingPassword} required | |
className={`w-full bg-gray-800 border ${formErrors.currentPassword ? 'border-red-500' : 'border-gray-700'} rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-orangered`} /> | |
{formErrors.currentPassword && <p className="mt-1 text-xs text-red-500">{formErrors.currentPassword}</p>} | |
</div> | |
<div> | |
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-400 mb-1">New Password</label> | |
<input id="newPassword" name="newPassword" type="password" value={passwordForm.newPassword} onChange={handlePasswordFormChange} disabled={isSubmittingPassword} required | |
className={`w-full bg-gray-800 border ${formErrors.newPassword ? 'border-red-500' : 'border-gray-700'} rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-orangered`} /> | |
{formErrors.newPassword && <p className="mt-1 text-xs text-red-500">{formErrors.newPassword}</p>} | |
</div> | |
<div> | |
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-400 mb-1">Confirm New Password</label> | |
<input id="confirmPassword" name="confirmPassword" type="password" value={passwordForm.confirmPassword} onChange={handlePasswordFormChange} disabled={isSubmittingPassword} required | |
className={`w-full bg-gray-800 border ${formErrors.confirmPassword ? 'border-red-500' : 'border-gray-700'} rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-orangered`} /> | |
{formErrors.confirmPassword && <p className="mt-1 text-xs text-red-500">{formErrors.confirmPassword}</p>} | |
</div> | |
{/* Display general password error */} | |
{profileError && <p className="text-sm text-red-400">{profileError}</p>} | |
{/* Action Buttons */} | |
<div className="flex space-x-3 pt-2"> | |
<button type="submit" disabled={isSubmittingPassword} | |
className="flex-1 bg-orangered hover:bg-red-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center text-sm font-medium disabled:opacity-50"> | |
{isSubmittingPassword ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Update Password'} | |
</button> | |
<button type="button" onClick={() => { setShowPasswordChange(false); setFormErrors({}); setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); }} disabled={isSubmittingPassword} | |
className="flex-1 bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center text-sm font-medium disabled:opacity-50"> | |
Cancel | |
</button> | |
</div> | |
</form> | |
</div> | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
}; | |
export default Profile; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/pages/Services.tsx | |
import React from 'react'; | |
import { Code, Cloud, Server, Database, Archive, Sparkles } from 'lucide-react'; | |
import { Link } from 'react-router-dom'; | |
// Import reusable components | |
import Card from '../components/Card'; | |
import CardGrid from '../components/CardGrid'; | |
const Services: React.FC = () => { // Added React.FC type | |
// Add actionLink to make cards clickable | |
const servicesData = [ | |
{ | |
icon: <Code className="h-10 w-10 sm:h-12 sm:w-12 text-orangered" />, | |
title: "Custom Development", | |
description: "WordPress sites, custom HTML, and modern web apps tailored to your needs.", | |
actionLink: "/websites" // Link to relevant page | |
}, | |
{ | |
icon: <Cloud className="h-10 w-10 sm:h-12 sm:w-12 text-orangered" />, | |
title: "Cloud Hosting", | |
description: "Reliable cloud hosting with high performance, scalability, and 99.9% uptime.", | |
actionLink: "/hosting#shared-hosting" // Link to relevant page/section | |
}, | |
{ | |
icon: <Server className="h-10 w-10 sm:h-12 sm:w-12 text-orangered" />, | |
title: "VPS Hosting", | |
description: "Powerful Virtual Private Servers with full root access and dedicated resources.", | |
actionLink: "/hosting#vps-hosting" // Link to relevant page/section | |
}, | |
{ | |
icon: <Database className="h-10 w-10 sm:h-12 sm:w-12 text-orangered" />, | |
title: "DNS Management", | |
description: "Professional DNS management with advanced features for reliability and control.", | |
actionLink: "/services/dns" // Example link (needs corresponding page/route) | |
}, | |
{ | |
icon: <Archive className="h-10 w-10 sm:h-12 sm:w-12 text-orangered" />, | |
title: "Incremental Backup", | |
description: "Secure incremental backups ensuring your data is always protected and recoverable.", | |
actionLink: "/services/backup" // Example link | |
}, | |
{ | |
icon: <Sparkles className="h-10 w-10 sm:h-12 sm:w-12 text-orangered" />, | |
title: "Custom Solutions", | |
description: "Tailored hosting and development solutions designed for unique requirements.", | |
actionLink: "/contact?subject=Custom+Solution" // Link to contact with subject | |
} | |
]; | |
return ( | |
// Use main, add pt-16 assuming fixed Navbar | |
<main className="pt-16 min-h-screen bg-gradient-to-b from-black via-gray-900 to-black text-white"> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 sm:py-24"> | |
{/* Header Section */} | |
<section aria-labelledby="services-page-heading" className="text-center mb-12 sm:mb-16"> | |
<h1 id="services-page-heading" className="text-4xl sm:text-5xl font-bold text-white mb-4 sm:mb-6"> | |
Our <span className="text-orangered">Services</span> | |
</h1> | |
<p className="text-lg sm:text-xl text-gray-400 max-w-3xl mx-auto"> | |
Comprehensive technology solutions designed to empower your digital presence and drive growth. | |
</p> | |
</section> | |
{/* Services Grid Section */} | |
<section aria-labelledby="core-services-heading"> | |
<h2 id="core-services-heading" className="sr-only">Core Services</h2> {/* Hidden heading for structure */} | |
{/* Use CardGrid and Card */} | |
<CardGrid columns={{ sm: 1, md: 2, lg: 3 }} gap={8}> | |
{servicesData.map((service, index) => ( | |
<Card | |
key={index} | |
variant="feature" // Use feature variant styling | |
icon={service.icon} | |
title={service.title} | |
description={service.description} | |
actionLink={service.actionLink} // Pass the link to make the card clickable | |
// The Card component (refactored version) will wrap this in a Link if actionLink is provided | |
/> | |
))} | |
</CardGrid> | |
</section> | |
{/* Custom Solution CTA Section */} | |
<section aria-labelledby="custom-solution-heading" className="mt-16 sm:mt-24 bg-gradient-to-r from-orangered via-red-600 to-red-700 rounded-2xl p-8 sm:p-12 text-center shadow-xl"> | |
<h2 id="custom-solution-heading" className="text-3xl font-bold text-white mb-4"> | |
Need a Custom Solution? | |
</h2> | |
<p className="text-white/90 mb-8 max-w-2xl mx-auto text-lg"> | |
Our experts are ready to discuss your specific requirements and craft the perfect technology solution for your business challenges. | |
</p> | |
<Link | |
to="/contact?subject=Custom+Solution" // Pre-fill subject? | |
className="inline-block bg-white text-orangered px-8 py-3 rounded-lg font-semibold hover:bg-gray-200 transition-colors shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-white" // Added focus style | |
> | |
Get in Touch | |
</Link> | |
</section> | |
</div> | |
</main> | |
); | |
}; | |
export default Services; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// src/pages/Signup.tsx | |
import React, { useState } from 'react'; | |
import { useNavigate, Link } from 'react-router-dom'; // Import Link | |
import { Mail, Lock, User, ArrowLeft, Loader2, AlertTriangle } from 'lucide-react'; | |
// Corrected import path | |
import { useAuth } from '../components/AuthContext'; | |
import { z } from 'zod'; | |
import { toast } from 'react-hot-toast'; // Import toast for API errors | |
// Zod schema (keep as is, it's good) | |
const signupSchema = z.object({ | |
name: z.string().min(2, 'Name must be at least 2 characters'), | |
email: z.string().email('Invalid email address'), | |
password: z.string() | |
.min(8, 'Password must be at least 8 characters') | |
.regex(/[A-Z]/, 'Must contain one uppercase letter') | |
.regex(/[a-z]/, 'Must contain one lowercase letter') | |
.regex(/[0-9]/, 'Must contain one number') | |
.regex(/[^A-Za-z0-9]/, 'Must contain one special character'), | |
confirmPassword: z.string() | |
}).refine((data) => data.password === data.confirmPassword, { | |
message: "Passwords don't match", | |
path: ["confirmPassword"], | |
}); | |
type SignupFormData = z.infer<typeof signupSchema>; | |
const Signup: React.FC = () => { // Added React.FC type | |
const navigate = useNavigate(); | |
// Use correct hook, get isSubmitting state if needed (though signup handles its own) | |
const { signup, isSubmitting: isAuthSubmitting } = useAuth(); // Renamed isLoading to isSubmitting | |
const [formErrors, setFormErrors] = useState<Partial<Record<keyof SignupFormData, string>>>({}); | |
const [submitError, setSubmitError] = useState<string | null>(null); // For API errors | |
const [formData, setFormData] = useState<SignupFormData>({ | |
name: '', email: '', password: '', confirmPassword: '' | |
}); | |
// Input change handler | |
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
const { id, value } = e.target; | |
setFormData(prev => ({ ...prev, [id]: value })); | |
// Clear validation error on change | |
if (formErrors[id as keyof SignupFormData]) { | |
setFormErrors(prev => ({ ...prev, [id]: undefined })); | |
} | |
setSubmitError(null); // Clear general submit error | |
}; | |
// Form submission handler | |
const handleSubmit = async (e: React.FormEvent) => { | |
e.preventDefault(); | |
setFormErrors({}); | |
setSubmitError(null); | |
// No need for local isLoading if using isAuthSubmitting from context | |
// --- 1. Validation --- | |
const validationResult = signupSchema.safeParse(formData); | |
if (!validationResult.success) { | |
const fieldErrors: Partial<Record<keyof SignupFormData, string>> = {}; | |
validationResult.error.errors.forEach(err => { | |
if (err.path.length > 0) { | |
fieldErrors[err.path[0] as keyof SignupFormData] = err.message; | |
} | |
}); | |
setFormErrors(fieldErrors); | |
toast.error("Please fix the errors in the form."); | |
return; | |
} | |
// --- 2. API Call via Auth Context --- | |
try { | |
// Destructure confirmPassword, as signup function doesn't need it | |
const { confirmPassword, ...signupData } = validationResult.data; | |
// Call the signup function from context | |
await signup(signupData.email, signupData.password, signupData.name); | |
// SUCCESS: signup function handles toast & navigation to /login | |
// No need to navigate('/dashboard') here. | |
// Optionally clear form, though navigation will unmount it | |
// setFormData({ name: '', email: '', password: '', confirmPassword: '' }); | |
} catch (error: any) { | |
// --- 3. Handle API Errors --- | |
console.error("Signup API error:", error); | |
const message = error.message || 'Failed to create account. Please try again.'; | |
setSubmitError(message); // Display error within the form | |
toast.error(message); // Also show a toast | |
} | |
// No finally block needed if using context's isSubmitting | |
}; | |
return ( | |
<div className="min-h-screen bg-gradient-to-b from-black via-gray-900 to-black flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8"> | |
<div className="max-w-md w-full space-y-8 bg-gray-900/50 backdrop-blur-sm p-6 sm:p-8 rounded-xl border border-gray-800/50 shadow-xl"> | |
<div> | |
{/* Back Button */} | |
<button | |
onClick={() => navigate('/')} // Or navigate(-1) to go back | |
className="flex items-center text-sm text-gray-400 hover:text-white transition-colors mb-6 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 rounded" | |
aria-label="Back to Home" | |
> | |
<ArrowLeft className="h-4 w-4 mr-1.5" /> | |
Back to Home | |
</button> | |
{/* Header */} | |
<h2 className="text-2xl sm:text-3xl font-bold text-white text-center"> | |
Create Your Account | |
</h2> | |
<p className="mt-2 text-center text-sm text-gray-400"> | |
Join us and experience premium hosting services. | |
</p> | |
</div> | |
{/* Signup Form */} | |
<form className="mt-8 space-y-5" onSubmit={handleSubmit}> | |
{/* General Submit Error */} | |
{submitError && ( | |
<div className="bg-red-600/10 border border-red-600/30 text-red-400 px-4 py-2 rounded-lg text-sm"> | |
<AlertTriangle className="inline w-4 h-4 mr-2" /> {submitError} | |
</div> | |
)} | |
{/* Inputs */} | |
<div className="space-y-4 rounded-md shadow-sm"> | |
{/* Name */} | |
<div> | |
<label htmlFor="name" className="block text-sm font-medium text-gray-400 mb-1.5">Full Name</label> | |
<div className="relative"> | |
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500 pointer-events-none" /> | |
<input id="name" type="text" value={formData.name} onChange={handleInputChange} required disabled={isAuthSubmitting} | |
className={`w-full bg-gray-800 border ${formErrors.name ? 'border-red-500' : 'border-gray-700'} rounded-lg pl-10 pr-4 py-2.5 text-white focus:outline-none focus:ring-1 ${formErrors.name ? 'focus:ring-red-500' : 'focus:ring-orangered'} disabled:opacity-50`} | |
placeholder="John Doe" /> | |
</div> | |
{formErrors.name && <p className="mt-1 text-xs text-red-400">{formErrors.name}</p>} | |
</div> | |
{/* Email */} | |
<div> | |
<label htmlFor="email" className="block text-sm font-medium text-gray-400 mb-1.5">Email Address</label> | |
<div className="relative"> | |
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500 pointer-events-none" /> | |
<input id="email" type="email" value={formData.email} onChange={handleInputChange} required disabled={isAuthSubmitting} | |
className={`w-full bg-gray-800 border ${formErrors.email ? 'border-red-500' : 'border-gray-700'} rounded-lg pl-10 pr-4 py-2.5 text-white focus:outline-none focus:ring-1 ${formErrors.email ? 'focus:ring-red-500' : 'focus:ring-orangered'} disabled:opacity-50`} | |
placeholder="[email protected]" /> | |
</div> | |
{formErrors.email && <p className="mt-1 text-xs text-red-400">{formErrors.email}</p>} | |
</div> | |
{/* Password */} | |
<div> | |
<label htmlFor="password" className="block text-sm font-medium text-gray-400 mb-1.5">Password</label> | |
<div className="relative"> | |
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500 pointer-events-none" /> | |
<input id="password" type="password" value={formData.password} onChange={handleInputChange} required disabled={isAuthSubmitting} | |
className={`w-full bg-gray-800 border ${formErrors.password ? 'border-red-500' : 'border-gray-700'} rounded-lg pl-10 pr-4 py-2.5 text-white focus:outline-none focus:ring-1 ${formErrors.password ? 'focus:ring-red-500' : 'focus:ring-orangered'} disabled:opacity-50`} | |
placeholder="••••••••" /> | |
</div> | |
{formErrors.password && <p className="mt-1 text-xs text-red-400">{formErrors.password}</p>} | |
</div> | |
{/* Confirm Password */} | |
<div> | |
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-400 mb-1.5">Confirm Password</label> | |
<div className="relative"> | |
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-500 pointer-events-none" /> | |
<input id="confirmPassword" type="password" value={formData.confirmPassword} onChange={handleInputChange} required disabled={isAuthSubmitting} | |
className={`w-full bg-gray-800 border ${formErrors.confirmPassword ? 'border-red-500' : 'border-gray-700'} rounded-lg pl-10 pr-4 py-2.5 text-white focus:outline-none focus:ring-1 ${formErrors.confirmPassword ? 'focus:ring-red-500' : 'focus:ring-orangered'} disabled:opacity-50`} | |
placeholder="••••••••" /> | |
</div> | |
{formErrors.confirmPassword && <p className="mt-1 text-xs text-red-400">{formErrors.confirmPassword}</p>} | |
</div> | |
</div> | |
{/* Submit Button */} | |
<button type="submit" disabled={isAuthSubmitting} | |
className="w-full mt-6 flex justify-center items-center bg-orangered hover:bg-red-600 text-white px-4 py-2.5 rounded-lg transition-colors font-semibold disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 focus-visible:ring-orangered"> | |
{isAuthSubmitting ? ( | |
<Loader2 className="h-5 w-5 animate-spin" /> | |
) : ( | |
'Create Account' | |
)} | |
</button> | |
{/* Link to Login */} | |
<p className="text-center text-sm text-gray-400 pt-2"> | |
Already have an account?{' '} | |
{/* Use Link component for navigation */} | |
<Link | |
to="/login" | |
className="font-medium text-orangered hover:text-red-600 transition-colors focus:outline-none focus-visible:underline" | |
> | |
Log In | |
</Link> | |
</p> | |
</form> | |
</div> | |
</div> | |
); | |
}; | |
export default Signup; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment