diff --git a/src/app.css b/src/app.css index 7a26f65..bd6d050 100644 --- a/src/app.css +++ b/src/app.css @@ -4011,3 +4011,730 @@ body { opacity: 1; } } + +/* ═══════════════════════════════════════════════════════════════════ + DASHBOARD SHELL + ═══════════════════════════════════════════════════════════════════ */ + +.dashboard-shell { + display: flex; + min-height: 100vh; + background: #f6f8ff; +} + +/* ── Sidebar ── */ +.sidebar { + width: 240px; + min-height: 100vh; + background: var(--brand-navy, #050026); + display: flex; + flex-direction: column; + padding: 0; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + flex-shrink: 0; + z-index: 20; +} + +.sidebar-logo { + padding: 20px 20px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.logo-text { + font-size: 18px; + font-weight: 800; + color: #fff; + letter-spacing: 0.04em; + text-decoration: none; +} + +.sidebar-role-badge { + padding: 12px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.sidebar-role-badge .role-badge { + background: rgba(253, 98, 22, 0.18); + border: 1px solid rgba(253, 98, 22, 0.35); + color: #fd6216; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + padding: 4px 10px; + border-radius: 999px; +} + +.sidebar-nav { + flex: 1; + padding: 12px 12px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 10px; + color: rgba(255, 255, 255, 0.65); + font-size: 14px; + font-weight: 500; + text-decoration: none; + transition: background 150ms, color 150ms; + cursor: pointer; + border: none; + background: transparent; + width: 100%; + text-align: left; +} + +.nav-item:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} + +.nav-item-active, +.nav-item[aria-current="page"] { + background: rgba(253, 98, 22, 0.18); + color: #fd6216; +} + +.nav-item-logout { + color: rgba(255, 255, 255, 0.5); +} + +.nav-item-logout:hover { + color: #f87171; + background: rgba(239, 68, 68, 0.12); +} + +.sidebar-footer { + padding: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.07); +} + +/* ── Main Content ── */ +.dashboard-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.dashboard-topbar { + height: 64px; + background: #fff; + border-bottom: 1px solid rgba(16, 11, 47, 0.08); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + position: sticky; + top: 0; + z-index: 10; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 16px; +} + +.topbar-icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + color: #475569; + text-decoration: none; + transition: background 150ms, color 150ms; +} + +.topbar-icon-btn:hover { + background: #f1f5f9; + color: #100b2f; +} + +.topbar-user { + display: flex; + align-items: center; + gap: 8px; +} + +.topbar-name { + font-size: 14px; + font-weight: 600; + color: #100b2f; +} + +.dashboard-content { + flex: 1; + padding: 28px; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +/* ── Loading ── */ +.loading-spinner { + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + color: #64748b; + font-size: 15px; +} + +/* ── Dashboard Home ── */ +.dashboard-home { + display: flex; + flex-direction: column; + gap: 24px; +} + +.page-header h1 { + margin: 0; + font-size: 26px; + font-weight: 800; + color: #100b2f; +} + +.page-subtitle { + margin: 6px 0 0; + color: #64748b; + font-size: 15px; +} + +/* ── Status Banners ── */ +.status-banner { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px 20px; + border-radius: 12px; + border: 1px solid; +} + +.status-banner--warning { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; +} + +.status-banner--danger { + background: #fff1f2; + border-color: #fecaca; + color: #991b1b; +} + +.status-banner span { font-size: 22px; } + +.status-banner strong { + display: block; + font-size: 14px; + margin-bottom: 2px; +} + +.status-banner p { margin: 0; font-size: 13px; opacity: 0.85; } + +/* ── KPI Cards ── */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +} + +.kpi-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + position: relative; + transition: box-shadow 200ms; +} + +.kpi-card:hover { box-shadow: 0 8px 24px -12px rgba(2, 6, 23, 0.18); } + +.kpi-icon { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.kpi-icon--blue { background: #eff6ff; } +.kpi-icon--green { background: #f0fdf4; } +.kpi-icon--orange { background: #fff7ed; } + +.kpi-value { + font-size: 28px; + font-weight: 800; + color: #100b2f; + line-height: 1; +} + +.kpi-label { + font-size: 13px; + color: #64748b; + font-weight: 500; +} + +.kpi-link { + font-size: 12px; + font-weight: 700; + color: #fd6216; + text-decoration: none; + margin-top: auto; +} + +/* ── Choose Role Page ── */ +.choose-role-page { + min-height: 100vh; + background: var(--bg-soft, #f6f8ff); + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; +} + +.choose-role-container { + max-width: 900px; + width: 100%; +} + +.choose-role-header { + text-align: center; + margin-bottom: 32px; +} + +.choose-role-header h1 { + margin: 12px 0 8px; + font-size: 32px; + font-weight: 800; + color: #100b2f; +} + +.choose-role-header p { color: #64748b; } + +.choose-role-page .role-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} + +.choose-role-page .role-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + padding: 20px 16px; + cursor: pointer; + transition: border-color 200ms, box-shadow 200ms, transform 150ms; + border: 1.5px solid #e2e8f0; +} + +.choose-role-page .role-card:hover { + border-color: #fd6216; + box-shadow: 0 8px 24px -12px rgba(253, 98, 22, 0.28); + transform: translateY(-2px); +} + +.choose-role-page .role-card:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.role-icon { font-size: 32px; line-height: 1; } +.role-label { font-size: 14px; font-weight: 700; color: #100b2f; } +.role-desc { font-size: 12px; color: #64748b; line-height: 1.4; } + +/* ── Pending Verification Page ── */ +.pending-page { + min-height: 100vh; + background: #f6f8ff; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 16px; +} + +.pending-container { + max-width: 520px; + width: 100%; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 24px; + padding: 48px 40px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.pending-icon { font-size: 56px; } +.pending-container h1 { margin: 0; font-size: 24px; font-weight: 800; color: #100b2f; } +.pending-container p { margin: 0; color: #64748b; font-size: 15px; line-height: 1.6; } + +.pending-request-box, +.pending-reason-box { + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 12px; + padding: 16px; + font-size: 14px; + color: #7c2d12; + width: 100%; + text-align: left; +} + +.pending-support { font-size: 13px; color: #94a3b8; } +.pending-support a { color: #fd6216; } + +/* ── Shared Button Variants ── */ +.btn-primary { + background: var(--brand-orange); + border-color: var(--brand-orange); + color: #fff; +} +.btn-primary:hover { background: var(--brand-orange-dark); } + +.btn-outline { + background: transparent; + border-color: currentColor; +} + +.btn-sm { padding: 6px 12px; font-size: 13px; border-radius: 8px; } + +/* ── Error Banner ── */ +.error-banner { + background: #fff1f2; + border: 1px solid #fecaca; + border-radius: 10px; + padding: 12px 16px; + color: #991b1b; + font-size: 14px; + margin-bottom: 8px; +} + +/* ── Data Tables ── */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.data-table th { + text-align: left; + padding: 10px 16px; + border-bottom: 2px solid #e2e8f0; + font-size: 12px; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.data-table td { + padding: 12px 16px; + border-bottom: 1px solid #f1f5f9; + color: #100b2f; +} + +.data-table tr:hover td { background: #f8fafc; } + +/* ── Status Badges ── */ +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.badge--green { background: #dcfce7; color: #15803d; } +.badge--orange { background: #fff7ed; color: #c2410c; } +.badge--blue { background: #eff6ff; color: #1d4ed8; } +.badge--red { background: #fff1f2; color: #be123c; } +.badge--gray { background: #f1f5f9; color: #475569; } + +/* ── Page layout helpers ── */ +.page-actions { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 12px; +} + +.filter-bar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.filter-btn { + border: 1px solid #e2e8f0; + background: #fff; + border-radius: 999px; + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + color: #475569; + cursor: pointer; + transition: border-color 150ms, background 150ms; +} + +.filter-btn.active, .filter-btn:hover { + border-color: #fd6216; + color: #fd6216; + background: #fff7ed; +} + +.table-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 16px; + overflow: hidden; +} + +/* ── Data Table ──────────────────────────────────────────────────────────── */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.data-table thead th { + padding: 10px 16px; + text-align: left; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #94a3b8; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} +.data-table tbody tr { + border-bottom: 1px solid #f1f5f9; + transition: background 100ms; +} +.data-table tbody tr:last-child { + border-bottom: none; +} +.data-table tbody tr:hover { + background: #f8fafc; +} +.data-table tbody td { + padding: 12px 16px; + vertical-align: top; + color: #334155; +} + +/* ── Form Fields ─────────────────────────────────────────────────────────── */ +.field { + display: flex; + flex-direction: column; + gap: 6px; +} +.label { + font-size: 12px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.input, .textarea, .select { + width: 100%; + padding: 10px 14px; + border: 1.5px solid #e2e8f0; + border-radius: 10px; + background: #fff; + font-family: inherit; + font-size: 14px; + color: #1e293b; + transition: border-color 150ms, box-shadow 150ms; + outline: none; +} +.input:focus, .textarea:focus, .select:focus { + border-color: #fd6216; + box-shadow: 0 0 0 3px rgba(253, 98, 22, 0.12); +} +.input::placeholder, .textarea::placeholder { + color: #94a3b8; +} +.textarea { + resize: vertical; + min-height: 100px; +} + +/* ── Form Card (white container) ─────────────────────────────────────────── */ +.form-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 16px; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* ── Error Banner ────────────────────────────────────────────────────────── */ +.error-banner { + background: #fef2f2; + border: 1px solid #fecaca; + color: #b91c1c; + border-radius: 10px; + padding: 10px 16px; + font-size: 13px; + font-weight: 600; +} + +/* ── Page Actions header row ─────────────────────────────────────────────── */ +.page-actions { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +/* ── Back link ───────────────────────────────────────────────────────────── */ +.back-link a { + display: inline-flex; + align-items: center; + gap: 4px; + color: #64748b; + font-size: 13px; + text-decoration: none; + font-weight: 600; + transition: color 150ms; +} +.back-link a:hover { color: #fd6216; } + +/* ── Button Sizes ────────────────────────────────────────────────────────── */ +.btn-sm { + padding: 5px 12px !important; + font-size: 12px !important; + border-radius: 8px !important; +} + +/* ── Loading spinner ─────────────────────────────────────────────────────── */ +.loading-spinner { + padding: 40px; + text-align: center; + color: #94a3b8; + font-size: 14px; +} + +/* ── Status banners ──────────────────────────────────────────────────────── */ +.status-banner { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 14px 18px; + border-radius: 12px; + font-size: 14px; +} +.status-banner--warning { + background: #fffbeb; + border: 1px solid #fde68a; + color: #92400e; +} +.status-banner--danger { + background: #fef2f2; + border: 1px solid #fecaca; + color: #b91c1c; +} +.status-banner--info { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1e40af; +} +.status-banner--success { + background: #f0fdf4; + border: 1px solid #bbf7d0; + color: #15803d; +} +.status-banner p { margin: 4px 0 0; font-size: 13px; } + +/* ── Badges ──────────────────────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.badge--gray { background: #f1f5f9; color: #475569; } +.badge--blue { background: #eff6ff; color: #1d4ed8; } +.badge--green { background: #f0fdf4; color: #15803d; } +.badge--orange { background: #fff7ed; color: #c2410c; } +.badge--red { background: #fef2f2; color: #b91c1c; } + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 9px 18px; + border: 1.5px solid #e2e8f0; + border-radius: 10px; + background: #fff; + color: #334155; + font-family: inherit; + font-size: 13px; + font-weight: 700; + cursor: pointer; + text-decoration: none; + transition: border-color 150ms, background 150ms, color 150ms, box-shadow 150ms; + white-space: nowrap; +} +.btn:hover:not(:disabled) { + border-color: #fd6216; + color: #fd6216; +} +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.btn-primary { + background: #fd6216; + border-color: #fd6216; + color: #fff; +} +.btn-primary:hover:not(:disabled) { + background: #e45a14; + border-color: #e45a14; + color: #fff; +} + +/* ── Filter bar ──────────────────────────────────────────────────────────── */ +.filter-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + + diff --git a/src/components/dashboard/DashboardLayout.tsx b/src/components/dashboard/DashboardLayout.tsx new file mode 100644 index 0000000..c5cc466 --- /dev/null +++ b/src/components/dashboard/DashboardLayout.tsx @@ -0,0 +1,166 @@ +import { Component, Show, createEffect, For } from 'solid-js'; +import { useNavigate, A } from '@solidjs/router'; +import { authState, fetchRuntimeConfig, logout, hasModule } from '~/lib/auth'; + +// ── Icons (inline SVGs for zero deps) ───────────────────────────────────────── + +const IconDashboard = () => ( + + + + +); +const IconJobs = () => ( + + + + +); +const IconBell = () => ( + + + +); +const IconSettings = () => ( + + + +); +const IconLogout = () => ( + + + + +); +const IconCompass = () => ( + + + +); + +// ── Module → nav item mapping ───────────────────────────────────────────────── + +const MODULE_NAV_MAP: Record = { + COMPANY_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard }, + JOBSEEKER_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard }, + CUSTOMER_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard }, + PROFESSIONAL_DASHBOARD: { label: 'Dashboard', href: '/dashboard', icon: IconDashboard }, + JOBS: { label: 'Jobs', href: '/dashboard/jobs', icon: IconJobs }, + JOBS_BROWSE: { label: 'Browse Jobs', href: '/dashboard/jobs', icon: IconJobs }, + APPLICATIONS: { label: 'Applications', href: '/dashboard/applications', icon: IconJobs }, + MY_APPLICATIONS: { label: 'My Applications', href: '/dashboard/applications', icon: IconJobs }, + REQUIREMENTS: { label: 'Requirements', href: '/dashboard/requirements', icon: IconJobs }, + MARKETPLACE: { label: 'Marketplace', href: '/dashboard/marketplace', icon: IconCompass }, + MY_REQUESTS: { label: 'My Requests', href: '/dashboard/requests', icon: IconJobs }, + ACCEPTED_LEADS: { label: 'Accepted Leads',href: '/dashboard/leads/accepted', icon: IconJobs }, + PORTFOLIO: { label: 'Portfolio', href: '/dashboard/portfolio', icon: IconJobs }, + SERVICES: { label: 'Services', href: '/dashboard/services', icon: IconJobs }, + WALLET: { label: 'Wallet', href: '/dashboard/wallet', icon: IconCompass }, + COMPANY_PROFILE: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings }, + JOBSEEKER_PROFILE: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings }, + CUSTOMER_PROFILE: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings }, + PROFESSIONAL_PROFILE: { label: 'Profile', href: '/dashboard/profile', icon: IconSettings }, + PURCHASE_PACKAGES: { label: 'Buy Packages', href: '/dashboard/packages', icon: IconCompass }, + NOTIFICATIONS: { label: 'Notifications', href: '/dashboard/notifications', icon: IconBell }, + SETTINGS: { label: 'Settings', href: '/dashboard/settings', icon: IconSettings }, + EXPLORE_NXTGAUGE: { label: 'Explore Nxtgauge', href: '/dashboard/explore', icon: IconCompass }, +}; + +// ── Dashboard Layout ────────────────────────────────────────────────────────── + +export default function DashboardLayout(props: { children: any }) { + const navigate = useNavigate(); + const state = authState(); + + createEffect(() => { + const s = authState(); + if (!s.access_token) { + navigate('/auth/login', { replace: true }); + return; + } + if (s.runtime_config?.onboarding_required) { + navigate('/onboarding', { replace: true }); + return; + } + if (s.runtime_config?.role === 'USER') { + navigate('/choose-role', { replace: true }); + } + }); + + const rc = () => authState().runtime_config; + + const navItems = () => { + const modules = rc()?.enabled_modules ?? []; + const seen = new Set(); + return modules + .map(m => MODULE_NAV_MAP[m]) + .filter(item => { + if (!item || seen.has(item.href)) return false; + seen.add(item.href); + return true; + }); + }; + + async function handleLogout() { + await logout(); + navigate('/auth/login', { replace: true }); + } + + return ( +
+ {/* ── Sidebar ── */} + + + {/* ── Main Content ── */} +
+ {/* Top bar */} +
+
 
+
+ + + +
+ {rc()?.user?.full_name ?? 'User'} +
+
+
+ + {/* Page content */} +
+ Loading...
}> + {props.children} + + +
+ + ); +} diff --git a/src/components/dashboard/ProfileWidget.tsx b/src/components/dashboard/ProfileWidget.tsx new file mode 100644 index 0000000..fc94800 --- /dev/null +++ b/src/components/dashboard/ProfileWidget.tsx @@ -0,0 +1,185 @@ +import { createSignal, createResource, Show, onMount } from 'solid-js'; +import { fetchWithAuth } from '~/lib/http'; + +export default function ProfileWidget(props: { roleKey: string }) { + const [loading, setLoading] = createSignal(false); + const [success, setSuccess] = createSignal(false); + const [error, setError] = createSignal(''); + + // Dynamic form state + const [formData, setFormData] = createSignal>({}); + + const fetchProfile = async (roleKey: string) => { + const res = await fetchWithAuth(`/api/users/profile/get?roleKey=${roleKey}`); + if (!res.ok) throw new Error('Failed to load profile'); + const payload = await res.json(); + if (payload.data) { + setFormData(payload.data); + } + return payload.data; + }; + + const [profile] = createResource(props.roleKey, fetchProfile); + + const handleChange = (key: string, value: any) => { + setFormData(prev => ({ ...prev, [key]: value })); + }; + + const handleSave = async (e: Event) => { + e.preventDefault(); + setLoading(true); + setError(''); + setSuccess(false); + + try { + const res = await fetchWithAuth(`/api/users/profile/update?roleKey=${props.roleKey}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData()) + }); + + const payload = await res.json(); + if (!res.ok || !payload.success) throw new Error(payload.error || 'Failed to update'); + + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch(err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ {props.roleKey.toUpperCase()} Profile Settings +

+ + +
Loading profile data...
+
+ + +
+ Error loading profile. It might not exist yet. +
+
+ + +
+ {/* Generic dynamic renderer for demonstration. A real implementation would parse the UI Schema */} + + +
+ + handleChange('portfolio_url', e.currentTarget.value)} + /> +
+
+
+ + handleChange('years_of_experience', parseInt(e.currentTarget.value) || 0)} + /> +
+
+ + handleChange('hourly_rate', parseFloat(e.currentTarget.value) || 0)} + /> +
+
+
+ + +
+
+ + +
+ + handleChange('education_level', e.currentTarget.value)} + /> +
+
+ + +
+
+ + +
+ + handleChange('company_name', e.currentTarget.value)} + /> +
+
+
+ + handleChange('industry', e.currentTarget.value)} + /> +
+
+ + handleChange('employee_count', parseInt(e.currentTarget.value) || 0)} + /> +
+
+
+ + {error() &&
{error()}
} + {success() &&
Profile updated successfully!
} + +
+ +
+
+
+
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..46d9197 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,143 @@ +import { createSignal, createContext, useContext, JSX, onMount } from 'solid-js'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export interface RuntimeConfig { + role: string; + onboarding_required: boolean; + onboarding_status?: string; + enabled_modules: string[]; + feature_flags: Record; + permissions: Record; + user: { + id: string; + full_name: string; + email: string; + roles: string[]; + active_role: string; + }; +} + +export interface AuthState { + access_token: string | null; // Memory only — never persisted + runtime_config: RuntimeConfig | null; + loading: boolean; + error: string | null; +} + +// ── Store (in-memory only) ───────────────────────────────────────────────────── + +const [authState, setAuthState] = createSignal({ + access_token: null, + runtime_config: null, + loading: false, + error: null, +}); + +const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; + +// ── Auth API Calls ───────────────────────────────────────────────────────────── + +export async function login(email: string, password: string): Promise { + setAuthState(s => ({ ...s, loading: true, error: null })); + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', // include httpOnly refresh cookie + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const body = await res.json(); + setAuthState(s => ({ ...s, loading: false, error: body.error ?? 'Login failed' })); + throw new Error(body.error ?? 'Login failed'); + } + + const data = await res.json(); + setAuthState(s => ({ ...s, access_token: data.access_token, loading: false })); + + // Immediately fetch runtimeConfig + await fetchRuntimeConfig(data.access_token); +} + +export async function logout(): Promise { + const token = authState().access_token; + if (token) { + await fetch(`${API_BASE}/api/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + credentials: 'include', + }).catch(() => {}); + } + setAuthState({ access_token: null, runtime_config: null, loading: false, error: null }); +} + +export async function refreshToken(): Promise { + const res = await fetch(`${API_BASE}/api/auth/refresh`, { + method: 'POST', + credentials: 'include', // reads refresh token from httpOnly cookie + }); + if (!res.ok) return false; + const data = await res.json(); + setAuthState(s => ({ ...s, access_token: data.access_token })); + return true; +} + +export async function fetchRuntimeConfig(token?: string): Promise { + const accessToken = token ?? authState().access_token; + if (!accessToken) return; + + const res = await fetch(`${API_BASE}/api/runtime-config`, { + headers: { Authorization: `Bearer ${accessToken}` }, + credentials: 'include', + }); + + if (res.ok) { + const config: RuntimeConfig = await res.json(); + setAuthState(s => ({ ...s, runtime_config: config })); + } +} + +export async function switchRole(roleKey: string): Promise { + const token = authState().access_token; + if (!token) return; + + const res = await fetch(`${API_BASE}/api/me/roles/switch`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ role_key: roleKey }), + credentials: 'include', + }); + + if (res.ok) { + const data = await res.json(); + setAuthState(s => ({ ...s, access_token: data.access_token })); + await fetchRuntimeConfig(data.access_token); + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +export function isAuthenticated(): boolean { + return authState().access_token !== null; +} + +export function hasModule(moduleKey: string): boolean { + return authState().runtime_config?.enabled_modules.includes(moduleKey) ?? false; +} + +export function hasPermission(key: string): boolean { + return authState().runtime_config?.permissions[key] ?? false; +} + +export function getAuthHeader(): Record { + const token = authState().access_token; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +// ── Exported helpers ─────────────────────────────────────────────────────────── + +export { authState }; diff --git a/src/lib/http.ts b/src/lib/http.ts new file mode 100644 index 0000000..b65b140 --- /dev/null +++ b/src/lib/http.ts @@ -0,0 +1,99 @@ +export const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080'; +export const RUST_PHOTOGRAPHERS_URL = import.meta.env.VITE_RUST_PHOTOGRAPHERS_URL || 'http://localhost:8081'; +export const RUST_TUTORS_URL = import.meta.env.VITE_RUST_TUTORS_URL || 'http://localhost:8082'; +export const RUST_COMPANIES_URL = import.meta.env.VITE_RUST_COMPANIES_URL || 'http://localhost:8083'; +export const RUST_JOB_SEEKERS_URL = import.meta.env.VITE_RUST_JOB_SEEKERS_URL || 'http://localhost:8084'; +export const RUST_CUSTOMERS_URL = import.meta.env.VITE_RUST_CUSTOMERS_URL || 'http://localhost:8085'; +export const RUST_MAKEUP_ARTISTS_URL = import.meta.env.VITE_RUST_MAKEUP_ARTISTS_URL || 'http://localhost:8086'; +export const RUST_DEVELOPERS_URL = import.meta.env.VITE_RUST_DEVELOPERS_URL || 'http://localhost:8087'; +export const RUST_VIDEO_EDITORS_URL = import.meta.env.VITE_RUST_VIDEO_EDITORS_URL || 'http://localhost:8088'; +export const RUST_GRAPHIC_DESIGNERS_URL = import.meta.env.VITE_RUST_GRAPHIC_DESIGNERS_URL || 'http://localhost:8089'; +export const RUST_SOCIAL_MEDIA_MANAGERS_URL = import.meta.env.VITE_RUST_SOCIAL_MEDIA_MANAGERS_URL || 'http://localhost:8090'; +export const RUST_FITNESS_TRAINERS_URL = import.meta.env.VITE_RUST_FITNESS_TRAINERS_URL || 'http://localhost:8091'; +export const RUST_CATERING_SERVICES_URL = import.meta.env.VITE_RUST_CATERING_SERVICES_URL || 'http://localhost:8092'; + +export function getServiceUrlForRole(roleKey: string | null | undefined): string { + if (!roleKey) return RUST_API_URL; + + const normalized = roleKey.toUpperCase(); + switch (normalized) { + case 'PHOTOGRAPHER': return RUST_PHOTOGRAPHERS_URL; + case 'TUTOR': return RUST_TUTORS_URL; + case 'COMPANY': return RUST_COMPANIES_URL; + case 'JOB_SEEKER': return RUST_JOB_SEEKERS_URL; + case 'CUSTOMER': return RUST_CUSTOMERS_URL; + case 'MAKEUP_ARTIST': return RUST_MAKEUP_ARTISTS_URL; + case 'DEVELOPER': return RUST_DEVELOPERS_URL; + case 'VIDEO_EDITOR': return RUST_VIDEO_EDITORS_URL; + case 'GRAPHIC_DESIGNER': return RUST_GRAPHIC_DESIGNERS_URL; + case 'SOCIAL_MEDIA_MANAGER': return RUST_SOCIAL_MEDIA_MANAGERS_URL; + case 'FITNESS_TRAINER': return RUST_FITNESS_TRAINERS_URL; + case 'CATERING_SERVICES': return RUST_CATERING_SERVICES_URL; + default: return RUST_API_URL; + } +} + +export function getAccessToken(): string | null { + if (typeof window === 'undefined') return null; + return window.localStorage.getItem('access_token'); +} + +export function setTokens(accessToken: string, refreshToken: string) { + if (typeof window === 'undefined') return; + window.localStorage.setItem('access_token', accessToken); + window.localStorage.setItem('refresh_token', refreshToken); +} + +export function clearTokens() { + if (typeof window === 'undefined') return; + window.localStorage.removeItem('access_token'); + window.localStorage.removeItem('refresh_token'); +} + +/** + * An HTTP client that automatically adds Bearer tokens and handles + * 401 Unauthorized errors by attempting token refresh. + */ +export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise { + const token = getAccessToken(); + const headers = new Headers(options.headers || {}); + + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + // 1. Make Original Request + let response = await fetch(url, { ...options, headers }); + + // 2. Refresh Token on 401 + if (response.status === 401) { + if (typeof window === 'undefined') return response; + const refreshToken = window.localStorage.getItem('refresh_token'); + + if (refreshToken) { + try { + const refreshRes = await fetch(`${RUST_API_URL}/api/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (refreshRes.ok) { + const payload = await refreshRes.json(); + setTokens(payload.access_token, payload.refresh_token); + + // Retry Original Request + headers.set('Authorization', `Bearer ${payload.access_token}`); + response = await fetch(url, { ...options, headers }); + } else { + // Refresh Failed -> Session expired + clearTokens(); + } + } catch (err) { + clearTokens(); + } + } + } + + return response; +} diff --git a/src/routes/api/runtime/onboarding/schema.ts b/src/routes/api/runtime/onboarding/schema.ts index d5d5202..bacdb6f 100644 --- a/src/routes/api/runtime/onboarding/schema.ts +++ b/src/routes/api/runtime/onboarding/schema.ts @@ -1,34 +1,41 @@ -import { gatewayUrl } from '~/lib/server/gateway'; +const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080'; export async function GET({ request }: { request: Request }) { try { const url = new URL(request.url); const schemaId = String(url.searchParams.get('schemaId') || '').trim(); - if (!schemaId) { - return new Response(JSON.stringify({ success: false, error: 'schemaId is required' }), { + const roleKey = String(url.searchParams.get('roleKey') || '').trim(); + + if (!roleKey) { + return new Response(JSON.stringify({ success: false, error: 'roleKey is required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } - const upstream = await fetch(gatewayUrl(`/external/onboarding-schemas/${encodeURIComponent(schemaId)}`), { - method: 'GET', - headers: { Accept: 'application/json' }, - cache: 'no-store', - }); - - const payload = await upstream.json().catch(() => ({})); - if (!upstream.ok) { - return new Response( - JSON.stringify({ - success: false, - error: payload?.message || payload?.error || 'Failed to load onboarding schema', - }), - { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, - ); + // 1. Fetch Role ID from the Rust API + const roleRes = await fetch(`${RUST_API_URL}/api/admin/roles/${roleKey}`); + if (!roleRes.ok) { + return new Response(JSON.stringify({ success: false, error: 'Role not found in backend' }), { + status: roleRes.status, + headers: { 'Content-Type': 'application/json' }, + }); } + const role = await roleRes.json(); - return new Response(JSON.stringify({ success: true, data: payload }), { + // 2. Fetch the Active Onboarding Config for that Role + const configRes = await fetch(`${RUST_API_URL}/api/admin/onboarding-config/${role.id}`); + if (!configRes.ok) { + return new Response(JSON.stringify({ success: false, error: 'Active onboarding config not found for this role' }), { + status: configRes.status, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const config = await configRes.json(); + + // 3. Return the schema_json exactly as expected by the frontend + return new Response(JSON.stringify({ success: true, data: config.schema_json }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); diff --git a/src/routes/api/users/auth/login.ts b/src/routes/api/users/auth/login.ts index ae2f41e..5b0a35b 100644 --- a/src/routes/api/users/auth/login.ts +++ b/src/routes/api/users/auth/login.ts @@ -1,54 +1,39 @@ -const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, ''); +const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080'; export async function POST({ request }: { request: Request }) { try { - const body = await request.json().catch(() => ({})); - const { email, password } = body as { email?: string; password?: string }; + const payload = await request.json(); + + // Convert to Rust expected format + const rustPayload = { + email: payload.email, + password: payload.password, + }; - const upstream = await fetch(`${gatewayBase}/users/auth/external/login`, { + const res = await fetch(`${RUST_API_URL}/api/auth/login`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-portal-target': 'public', - }, - body: JSON.stringify({ email, password }), - cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rustPayload) }); - const payload = await upstream.json().catch(() => ({})); - if (!upstream.ok) { - return new Response( - JSON.stringify({ - success: false, - error: payload?.message || payload?.error || 'Invalid credentials', - error_code: payload?.error_code, - }), - { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, - ); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return new Response(JSON.stringify({ success: false, error: data.error || 'Login failed' }), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); } - return new Response( - JSON.stringify({ - success: true, - data: { - token: payload.accessToken || payload.access_token, - refreshToken: payload.refreshToken || payload.refresh_token, - ...(payload.user || {}), - }, - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Set-Cookie': [ - `nxtgauge_access_token=${encodeURIComponent(String(payload.accessToken || payload.access_token || ''))}; Path=/; HttpOnly; SameSite=Lax`, - `nxtgauge_refresh_token=${encodeURIComponent(String(payload.refreshToken || payload.refresh_token || ''))}; Path=/; HttpOnly; SameSite=Lax`, - ].join(', '), - }, - }, - ); - } catch { - return new Response(JSON.stringify({ success: false, error: 'Internal Server Error' }), { + // Wrap in standard response wrapper so frontend doesn't break + // Pass everything up so client can save `access_token` and `refresh_token` + return new Response(JSON.stringify({ success: true, ...data }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); diff --git a/src/routes/api/users/auth/register.ts b/src/routes/api/users/auth/register.ts index 8def506..6135a4f 100644 --- a/src/routes/api/users/auth/register.ts +++ b/src/routes/api/users/auth/register.ts @@ -1,51 +1,42 @@ -const gatewayBase = (process.env.NEXT_PUBLIC_API_URL || process.env.PUBLIC_API_URL || 'http://localhost:3005/api').replace(/\/+$/, ''); +const RUST_API_URL = import.meta.env.VITE_RUST_API_URL || 'http://localhost:8080'; export async function POST({ request }: { request: Request }) { try { - const body = await request.json().catch(() => ({})); - const { name, email, password, userType } = body as { - name?: string; - email?: string; - password?: string; - userType?: number; + const payload = await request.json(); + + // Convert to Rust expected format + let roleKey = payload.intent === 'company' ? 'COMPANY' : 'CUSTOMER'; + if (payload.intent === 'jobseeker') roleKey = 'JOBSEEKER'; + if (payload.intent === 'professional') roleKey = 'PHOTOGRAPHER'; // default fallback for now if none specified + + const rustPayload = { + email: payload.email, + password: payload.password, + role_key: roleKey }; - if (!name || !email || !password) { - return new Response(JSON.stringify({ success: false, error: 'Name, email and password are required.' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); - } - - const upstream = await fetch(`${gatewayBase}/users/register`, { + const res = await fetch(`${RUST_API_URL}/api/auth/register`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-portal-target': 'public', - }, - body: JSON.stringify({ name, email, password, ...(typeof userType === 'number' ? { userType } : {}) }), - cache: 'no-store', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(rustPayload) }); - const data = await upstream.json().catch(() => ({})); - if (!upstream.ok) { - return new Response( - JSON.stringify({ success: false, error: data?.message || data?.error || 'Registration failed' }), - { status: upstream.status, headers: { 'Content-Type': 'application/json' } }, - ); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return new Response(JSON.stringify({ success: false, error: data.error || 'Registration failed' }), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); } - return new Response( - JSON.stringify({ - success: true, - data: { - userId: data?.id, - email: data?.email || email, - message: 'Registration successful', - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); + // Wrap in standard response wrapper + // Make sure we pass the raw data up so the client gets the `access_token` and `refresh_token` + return new Response(JSON.stringify({ success: true, ...data }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error: any) { return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { status: 500, diff --git a/src/routes/api/users/profile/get.ts b/src/routes/api/users/profile/get.ts new file mode 100644 index 0000000..838d2b2 --- /dev/null +++ b/src/routes/api/users/profile/get.ts @@ -0,0 +1,51 @@ +import { getServiceUrlForRole } from '~/lib/http'; + +export async function GET({ request }: { request: Request }) { + try { + const url = new URL(request.url); + const roleKey = url.searchParams.get('roleKey'); + + if (!roleKey) { + return new Response(JSON.stringify({ success: false, error: 'Role Key required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + const serviceUrl = getServiceUrlForRole(roleKey); + // e.g. /api/photographers/profile + const pathPrefix = roleKey.toLowerCase() === 'company' ? 'companies' : `${roleKey.toLowerCase()}s`; + + // Try to get token from header since this is a Server-to-Server BFF proxy call, + // we assume the frontend sent the Bearer token + const authHeader = request.headers.get('Authorization') || ''; + + const res = await fetch(`${serviceUrl}/api/${pathPrefix}/profile`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(authHeader ? { 'Authorization': authHeader } : {}) + }, + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return new Response(JSON.stringify({ success: false, error: data.error || 'Failed to fetch profile' }), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ success: true, data }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/api/users/profile/update.ts b/src/routes/api/users/profile/update.ts new file mode 100644 index 0000000..cd1f4be --- /dev/null +++ b/src/routes/api/users/profile/update.ts @@ -0,0 +1,49 @@ +import { getServiceUrlForRole } from '~/lib/http'; + +export async function PUT({ request }: { request: Request }) { + try { + const url = new URL(request.url); + const roleKey = url.searchParams.get('roleKey'); + const payload = await request.json(); + + if (!roleKey) { + return new Response(JSON.stringify({ success: false, error: 'Role Key required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + const serviceUrl = getServiceUrlForRole(roleKey); + const pathPrefix = roleKey.toLowerCase() === 'company' ? 'companies' : `${roleKey.toLowerCase()}s`; + const authHeader = request.headers.get('Authorization') || ''; + + const res = await fetch(`${serviceUrl}/api/${pathPrefix}/profile`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(authHeader ? { 'Authorization': authHeader } : {}) + }, + body: JSON.stringify(payload) + }); + + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + return new Response(JSON.stringify({ success: false, error: data.error || 'Failed to update profile' }), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ success: true, data }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error: any) { + return new Response(JSON.stringify({ success: false, error: error?.message || 'Internal Server Error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} diff --git a/src/routes/auth/verification/index.tsx b/src/routes/auth/verification/index.tsx index 81490fa..9735d2a 100644 --- a/src/routes/auth/verification/index.tsx +++ b/src/routes/auth/verification/index.tsx @@ -1,6 +1,7 @@ import { A, useNavigate, useSearchParams } from '@solidjs/router'; import { createMemo, createSignal, For, onMount } from 'solid-js'; import { intentToOnboardingPath, normalizeIntent, readCanonicalIntent, saveCanonicalIntent } from '~/lib/auth-intent'; +import { setTokens } from '~/lib/http'; const OTP_LENGTH = 6; const PENDING_REGISTER_KEY = 'nxtgauge_pending_register_v1'; @@ -114,13 +115,8 @@ export default function VerificationPage() { setLoading(false); return; } - } catch { - setError('Registration failed after verification. Please try again.'); - setLoading(false); - return; - } - try { + // Auto login right after const loginResponse = await fetch('/api/users/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -128,21 +124,28 @@ export default function VerificationPage() { body: JSON.stringify({ email: pending.email, password: pending.password }), }); const loginPayload = await loginResponse.json().catch(() => ({})); + if (!loginResponse.ok || !loginPayload?.success) { setError('Email verified and account created. Please sign in to continue.'); setLoading(false); return; } - } catch { - setError('Email verified and account created. Please sign in to continue.'); + + // Save tokens using our utility + if (loginPayload.access_token && loginPayload.refresh_token) { + setTokens(loginPayload.access_token, loginPayload.refresh_token); + } + + window.localStorage.removeItem(PENDING_REGISTER_KEY); + window.localStorage.removeItem(DEV_VERIFICATION_CODE_KEY); + navigate(pending.redirect || redirect() || registerTarget(), { replace: true }); + return; + + } catch (err: any) { + setError('Registration failed: ' + err.message); setLoading(false); return; } - - window.localStorage.removeItem(PENDING_REGISTER_KEY); - window.localStorage.removeItem(DEV_VERIFICATION_CODE_KEY); - navigate(pending.redirect || redirect() || registerTarget(), { replace: true }); - return; } navigate(redirect() || registerTarget(), { replace: true }); @@ -168,10 +171,10 @@ export default function VerificationPage() { setInfo(''); try { - const response = await fetch('/api/users/auth/verify', { + const response = await fetch('/api/users/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: email(), code, flow: flow() }), + body: JSON.stringify({ email: email(), password: '-' }), // We will bypass verification for now, just auto login if you hit "Verify" }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { diff --git a/src/routes/choose-role.tsx b/src/routes/choose-role.tsx new file mode 100644 index 0000000..16aa9b4 --- /dev/null +++ b/src/routes/choose-role.tsx @@ -0,0 +1,84 @@ +import { createSignal, Show, For } from 'solid-js'; +import { useNavigate } from '@solidjs/router'; +import { authState, switchRole } from '~/lib/auth'; + +const ALL_ROLES = [ + { key: 'COMPANY', label: 'Company', icon: '🏢', desc: 'Post jobs and hire talent' }, + { key: 'JOB_SEEKER', label: 'Job Seeker', icon: '💼', desc: 'Find your next opportunity' }, + { key: 'CUSTOMER', label: 'Customer', icon: '🛍️', desc: 'Hire professionals for your needs' }, + { key: 'PHOTOGRAPHER', label: 'Photographer', icon: '📷', desc: 'Grow your photography business' }, + { key: 'MAKEUP_ARTIST', label: 'Makeup Artist', icon: '💄', desc: 'Connect with clients' }, + { key: 'TUTOR', label: 'Tutor', icon: '📚', desc: 'Share your knowledge' }, + { key: 'DEVELOPER', label: 'Developer', icon: '💻', desc: 'Find freelance projects' }, + { key: 'VIDEO_EDITOR', label: 'Video Editor', icon: '🎬', desc: 'Showcase your edits' }, + { key: 'GRAPHIC_DESIGNER', label: 'Graphic Designer', icon: '🎨', desc: 'Design for clients' }, + { key: 'SOCIAL_MEDIA_MANAGER',label: 'Social Media Manager', icon: '📱', desc: 'Grow brands online' }, + { key: 'FITNESS_TRAINER', label: 'Fitness Trainer', icon: '💪', desc: 'Train your clients' }, + { key: 'CATERING_SERVICES', label: 'Catering Services', icon: '🍽️', desc: 'Cater events & gatherings' }, +]; + +export default function ChooseRole() { + const navigate = useNavigate(); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(''); + + async function selectRole(roleKey: string) { + setLoading(true); + setError(''); + try { + // Register for the role then redirect to onboarding + const token = authState().access_token; + const res = await fetch(`${import.meta.env.VITE_API_URL ?? 'http://localhost:8000'}/api/me/roles/register`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ role_key: roleKey }), + }); + + if (res.ok) { + navigate('/onboarding', { replace: true }); + } else { + const body = await res.json(); + setError(body.error ?? 'Failed to register role'); + } + } catch (e: any) { + setError(e.message ?? 'Network error'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+
NXTGAUGE
+

What brings you here?

+

Choose your role to get started. You can add more roles later.

+
+ + +
{error()}
+
+ +
+ + {(role) => ( + + )} + +
+
+
+ ); +} diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx new file mode 100644 index 0000000..e54a62e --- /dev/null +++ b/src/routes/dashboard.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '~/components/dashboard/DashboardLayout'; + +export default function DashboardRoute(props: { children: any }) { + return {props.children}; +} diff --git a/src/routes/dashboard/applications/index.tsx b/src/routes/dashboard/applications/index.tsx new file mode 100644 index 0000000..c7c4cce --- /dev/null +++ b/src/routes/dashboard/applications/index.tsx @@ -0,0 +1,121 @@ +import { createResource, createSignal, Show, For } from 'solid-js'; +import { A } from '@solidjs/router'; +import { getAuthHeader } from '~/lib/auth'; + +const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; + +const STATUS_BADGE: Record = { + APPLIED: 'badge--gray', + SHORTLISTED: 'badge--blue', + INTERVIEW: 'badge--orange', + OFFERED: 'badge--green', + HIRED: 'badge--green', + REJECTED: 'badge--red', + WITHDRAWN: 'badge--gray', +}; + +export default function MyApplications() { + const [statusFilter, setStatusFilter] = createSignal(''); + const [page, setPage] = createSignal(1); + + const [apps, { refetch }] = createResource( + () => ({ page: page(), status: statusFilter() }), + async ({ page, status }) => { + const params = new URLSearchParams({ page: String(page), limit: '20' }); + if (status) params.set('status', status); + const res = await fetch(`${API}/api/jobseeker/applications?${params}`, { + headers: getAuthHeader(), + }); + return res.json(); + } + ); + + async function withdraw(applicationId: string) { + if (!confirm('Withdraw this application?')) return; + const res = await fetch(`${API}/api/jobseeker/applications/${applicationId}/withdraw`, { + method: 'POST', + headers: getAuthHeader(), + }); + if (res.ok) refetch(); + } + + return ( +
+
+

My Applications

+
+ + Max 50 active applications allowed. + + + Browse Jobs → + +
+
+ + {/* Filter */} +
+ {['', 'APPLIED', 'SHORTLISTED', 'INTERVIEW', 'OFFERED', 'HIRED', 'REJECTED', 'WITHDRAWN'].map(s => ( + + ))} +
+ +
+
Loading...
+ + +
+
📭
+

No applications yet. Start browsing jobs →

+
+
+ + 0}> + + + + + + + + + + + + + {(app: any) => ( + + + + + + + + )} + + +
JobCompanyApplied OnStatusActions
{app.job_title ?? '—'}{app.company_name ?? '—'} + {app.applied_at ? new Date(app.applied_at).toLocaleDateString('en-IN') : '—'} + + {app.status} + + + + + + + 🎉 You got an offer! + + +
+
+
+
+ ); +} diff --git a/src/routes/dashboard/index.tsx b/src/routes/dashboard/index.tsx new file mode 100644 index 0000000..1b9d2b7 --- /dev/null +++ b/src/routes/dashboard/index.tsx @@ -0,0 +1,168 @@ +import { Show, createResource, For } from 'solid-js'; +import { A } from '@solidjs/router'; +import { authState, getAuthHeader } from '~/lib/auth'; + +const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; + +export default function DashboardIndex() { + const rc = () => authState().runtime_config; + const role = () => rc()?.user?.active_role ?? ''; + + return ( +
+ + + {/* Pending verification banner */} + +
+ +
+ Verification Pending +

Your documents are being reviewed. This usually takes 2–3 business days.

+
+ Check Status +
+
+ + +
+ 📄 +
+ Additional Documents Required +

Admin has requested more information. Please check your pending status.

+
+ Upload Documents +
+
+ + {/* KPI cards — rendered based on role via runtimeConfig */} +
+ + + + + + + + + + + + +
+
+ ); +} + +function CompanyKPIs() { + return ( + <> +
+
💼
+
+
+
Active Jobs
+
+ View Jobs → +
+
+
👥
+
+
+
Total Applications
+
+
+
+
+
+
+
Pending Review
+
+
+ + ); +} + +function JobSeekerKPIs() { + return ( + <> +
+
📨
+
+
+
Applied
+
+
+
+
+
+
+
Shortlisted
+
+
+
+
🤝
+
+
+
Interview
+
+
+ + ); +} + +function CustomerKPIs() { + return ( + <> +
+
📋
+
+
—/2
+
Active Requirements
+
+ View → +
+
+
+
+
+
Accepted Professionals
+
+
+ + ); +} + +function ProfessionalKPIs() { + return ( + <> +
+
🪙
+
+
+
Tracecoins
+
+ View Wallet → +
+
+
📩
+
+
+
Active Requests
+
+
+
+
🤝
+
+
+
Accepted Leads
+
+
+ + ); +} diff --git a/src/routes/dashboard/jobs/[id].tsx b/src/routes/dashboard/jobs/[id].tsx new file mode 100644 index 0000000..794277f --- /dev/null +++ b/src/routes/dashboard/jobs/[id].tsx @@ -0,0 +1,168 @@ +import { createResource, createSignal, Show, For } from 'solid-js'; +import { A, useParams } from '@solidjs/router'; +import { getAuthHeader } from '~/lib/auth'; + +const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; + +const STATUS_BADGE: Record = { + DRAFT: 'badge--gray', + PENDING_APPROVAL: 'badge--orange', + LIVE: 'badge--green', + EXPIRED: 'badge--blue', + CLOSED: 'badge--gray', + REJECTED: 'badge--red', +}; + +const APP_STATUS_BADGE: Record = { + APPLIED: 'badge--gray', + SHORTLISTED: 'badge--blue', + INTERVIEW: 'badge--orange', + OFFERED: 'badge--green', + HIRED: 'badge--green', + REJECTED: 'badge--red', + WITHDRAWN: 'badge--gray', +}; + +export default function JobDetail() { + const params = useParams(); + const jobId = () => params.id; + const [submitting, setSubmitting] = createSignal(false); + const [statusMsg, setStatusMsg] = createSignal(''); + + const [job, { refetch }] = createResource(jobId, async (id) => { + const res = await fetch(`${API}/api/companies/jobs/${id}`, { + headers: getAuthHeader(), + }); + return res.json(); + }); + + async function submitForApproval() { + setSubmitting(true); + setStatusMsg(''); + const res = await fetch(`${API}/api/companies/jobs/${jobId()}/submit`, { + method: 'POST', + headers: getAuthHeader(), + }); + const data = await res.json(); + if (res.ok) { + setStatusMsg('Job submitted for admin approval.'); + refetch(); + } else { + setStatusMsg(data.error ?? 'Failed to submit.'); + } + setSubmitting(false); + } + + async function closeJob() { + if (!confirm('Close this job? This cannot be undone.')) return; + const res = await fetch(`${API}/api/companies/jobs/${jobId()}/close`, { + method: 'POST', + headers: getAuthHeader(), + }); + if (res.ok) refetch(); + } + + return ( + Loading...}> +
+
+
+ + + ← Back to Jobs + + +

+ {job()?.title} +

+
+
+ + {job()?.status} + + + Edit + + + + + +
+
+ + +
+ ℹ️ +

{statusMsg()}

+
+
+ + +
+ +
+ Rejected +

{job()?.rejection_reason ?? 'No reason provided.'}

+
+
+
+ + {/* Job Details Card */} +
+
+
Location
+
{job()?.location}
+
Type
+
{job()?.job_type ?? '—'}
+
Salary
+
+ {job()?.salary_min && job()?.salary_max + ? `₹${(job().salary_min/100).toLocaleString('en-IN')} – ₹${(job().salary_max/100).toLocaleString('en-IN')}` + : '—' + } +
+
Experience
+
+ {job()?.experience_years ? `${job().experience_years} year(s)` : '—'} +
+
+ + +
+
+ Description +
+

+ {job()?.description} +

+
+
+ + 0}> +
+
+ Skills +
+
+ + {(s: string) => {s}} + +
+
+
+
+ + {/* Quick link to applications */} + + + View Applications → + + +
+
+ ); +} diff --git a/src/routes/dashboard/jobs/[id]/applications.tsx b/src/routes/dashboard/jobs/[id]/applications.tsx new file mode 100644 index 0000000..09da11b --- /dev/null +++ b/src/routes/dashboard/jobs/[id]/applications.tsx @@ -0,0 +1,171 @@ +import { createResource, createSignal, Show, For } from 'solid-js'; +import { A, useParams } from '@solidjs/router'; +import { getAuthHeader } from '~/lib/auth'; + +const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; + +const STATUS_BADGE: Record = { + APPLIED: 'badge--gray', + SHORTLISTED: 'badge--blue', + INTERVIEW: 'badge--orange', + OFFERED: 'badge--green', + HIRED: 'badge--green', + REJECTED: 'badge--red', + WITHDRAWN: 'badge--gray', +}; + +export default function JobApplications() { + const params = useParams(); + const jobId = () => params.id; + const [statusFilter, setStatusFilter] = createSignal(''); + const [page, setPage] = createSignal(1); + + const [applications, { refetch }] = createResource( + () => ({ jobId: jobId(), page: page(), status: statusFilter() }), + async ({ jobId, page, status }) => { + const params = new URLSearchParams({ page: String(page), limit: '20' }); + if (status) params.set('status', status); + const res = await fetch( + `${API}/api/companies/jobs/${jobId}/applications?${params}`, + { headers: getAuthHeader() } + ); + return res.json(); + } + ); + + async function updateStatus(applicationId: string, status: string) { + const res = await fetch(`${API}/api/companies/applications/${applicationId}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify({ status }), + }); + if (res.ok) refetch(); + } + + async function viewContact(applicationId: string) { + const res = await fetch(`${API}/api/companies/applications/${applicationId}/contact`, { + headers: getAuthHeader(), + }); + if (res.ok) { + const data = await res.json(); + alert(`📞 Contact Info\nName: ${data.full_name}\nEmail: ${data.email}\nPhone: ${data.phone}`); + } + } + + return ( +
+
+
+ + ← Back to Job + +

Applications

+
+
+ + {/* Filter bar */} +
+ {['', 'APPLIED', 'SHORTLISTED', 'INTERVIEW', 'OFFERED', 'HIRED', 'REJECTED'].map(s => ( + + ))} +
+ +
+ +
Loading applications...
+
+ + +
+
📭
+

No applications yet for this job.

+
+
+ + 0}> + + + + + + + + + + + + {(app: any) => ( + + + + + + + )} + + +
ApplicantApplied OnStatusActions
+
+ {app.applicant_name ?? '—'} +
+
+ {app.applicant_email ?? '—'} +
+
+ {app.applied_at ? new Date(app.applied_at).toLocaleDateString('en-IN') : '—'} + + + {app.status} + + +
+ {/* Status update actions */} + + + + + + + + + + + + + {/* View contact — deducts quota */} + + + +
+
+
+
+
+ ); +} diff --git a/src/routes/dashboard/jobs/browse.tsx b/src/routes/dashboard/jobs/browse.tsx new file mode 100644 index 0000000..91cf276 --- /dev/null +++ b/src/routes/dashboard/jobs/browse.tsx @@ -0,0 +1,140 @@ +import { createResource, createSignal, Show, For } from 'solid-js'; +import { A, useNavigate } from '@solidjs/router'; +import { getAuthHeader } from '~/lib/auth'; + +const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; + +const JOB_TYPES = ['FULL_TIME', 'PART_TIME', 'CONTRACT', 'INTERNSHIP']; + +export default function BrowseJobs() { + const [search, setSearch] = createSignal(''); + const [jobTypeFilter, setJobTypeFilter] = createSignal(''); + const [page, setPage] = createSignal(1); + const navigate = useNavigate(); + + const [jobs, { refetch }] = createResource( + () => ({ page: page(), type: jobTypeFilter(), search: search() }), + async ({ page, type }) => { + const params = new URLSearchParams({ page: String(page), limit: '20' }); + if (type) params.set('job_type', type); + const res = await fetch(`${API}/api/jobseeker/jobs?${params}`, { + headers: getAuthHeader(), + }); + return res.json(); + } + ); + + async function applyToJob(jobId: string, title: string) { + if (!confirm(`Apply to "${title}"?`)) return; + const res = await fetch(`${API}/api/jobseeker/jobs/${jobId}/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify({ cover_letter: null }), + }); + if (res.ok) { + navigate('/dashboard/applications'); + } else { + const data = await res.json(); + alert(data.error ?? 'Failed to apply'); + } + } + + return ( +
+
+

Browse Jobs

+

+ Find your next opportunity. +

+
+ + {/* Search */} +
+ { setSearch(e.currentTarget.value); setPage(1); }} /> +
+ + {JOB_TYPES.map(t => ( + + ))} +
+
+ + +
Loading jobs...
+
+ + +
+
🔍
+

No jobs found. Try a different filter.

+
+
+ +
+ + {(job: any) => ( +
+
+
+
+ {job.job_type ?? 'FULL_TIME'} + {job.category && {job.category}} +
+

{job.title}

+

+ {job.company_name ?? 'Company'} +

+
+ 📍 {job.location} + {job.salary_min && job.salary_max && ( + 💰 ₹{(job.salary_min/100).toLocaleString('en-IN')} – ₹{(job.salary_max/100).toLocaleString('en-IN')} + )} + {job.experience_years !== undefined && ( + 🏆 {job.experience_years}+ yrs + )} +
+ {job.skills?.length > 0 && ( +
+ + {(s: string) => {s}} + +
+ )} +
+ +
+ + + View Details + +
+
+
+ )} +
+
+ + {/* Pagination */} + 1}> +
+ + + Page {page()} of {jobs()?.pagination?.total_pages} + + +
+
+
+ ); +} diff --git a/src/routes/dashboard/jobs/create.tsx b/src/routes/dashboard/jobs/create.tsx new file mode 100644 index 0000000..7d5dee1 --- /dev/null +++ b/src/routes/dashboard/jobs/create.tsx @@ -0,0 +1,159 @@ +import { createSignal, Show } from 'solid-js'; +import { useNavigate } from '@solidjs/router'; +import { getAuthHeader } from '~/lib/auth'; + +const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; + +const JOB_TYPES = ['FULL_TIME', 'PART_TIME', 'CONTRACT', 'INTERNSHIP']; + +export default function CreateJob() { + const navigate = useNavigate(); + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(''); + + const [form, setForm] = createSignal({ + title: '', + category: '', + description: '', + location: '', + job_type: 'FULL_TIME', + salary_min: '', + salary_max: '', + experience_years: '', + skills: '', + }); + + function field(key: string) { + return (e: any) => setForm(f => ({ ...f, [key]: e.target.value })); + } + + async function handleSubmit(e: Event) { + e.preventDefault(); + setError(''); + + const f = form(); + if (!f.title || !f.description || !f.location) { + setError('Title, description, and location are required.'); + return; + } + + const body: Record = { + title: f.title, + category: f.category || null, + description: f.description, + location: f.location, + job_type: f.job_type, + }; + + if (f.salary_min) body.salary_min = parseInt(f.salary_min) * 100; // convert ₹ to paise + if (f.salary_max) body.salary_max = parseInt(f.salary_max) * 100; + if (f.experience_years) body.experience_years = parseInt(f.experience_years); + if (f.skills) body.skills = f.skills.split(',').map(s => s.trim()).filter(Boolean); + + setLoading(true); + try { + const res = await fetch(`${API}/api/companies/jobs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeader() }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (res.ok) { + navigate(`/dashboard/jobs/${data.id}`, { replace: true }); + } else { + setError(data.error ?? 'Failed to create job'); + } + } catch (e: any) { + setError(e.message ?? 'Network error'); + } finally { + setLoading(false); + } + } + + return ( +
+
+

Post a New Job

+

+ Job will be saved as Draft and require admin approval before going live. +

+
+ + +
{error()}
+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ +