feat(admin): wire management modules to live backend and add UGC role

This commit is contained in:
Ashwin Kumar 2026-04-02 13:09:45 +02:00
parent 2cd4cdcec3
commit 055dcd4175
31 changed files with 1369 additions and 763 deletions

View file

@ -5330,3 +5330,155 @@ body {
flex-wrap: wrap;
gap: 6px;
}
/* ── Dashboard Enhancements ────────────────────────────────────────────────── */
.dashboard-widgets-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
margin-top: 24px;
}
.widget-card {
background: #fff;
border: 1px solid rgba(16, 11, 47, 0.08);
border-radius: 20px;
padding: 20px;
box-shadow: 0 4px 20px -10px rgba(2, 6, 23, 0.1);
display: flex;
flex-direction: column;
gap: 16px;
}
.widget-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.widget-header h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--ink);
}
.dashboard-tabs {
display: flex;
gap: 8px;
padding: 4px;
background: #f1f5f9;
border-radius: 12px;
width: fit-content;
margin-bottom: 24px;
}
.dashboard-tab {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
color: #64748b;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.dashboard-tab--active {
background: #fff;
color: var(--brand-orange);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.quick-actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.quick-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 16px;
text-decoration: none;
transition: all 0.2s ease;
}
.quick-action-btn:hover {
background: #fff;
border-color: var(--brand-orange);
transform: translateY(-2px);
box-shadow: 0 8px 20px -10px rgba(253, 97, 22, 0.2);
}
.action-icon {
font-size: 24px;
}
.quick-action-btn span:not(.action-icon) {
font-size: 12px;
font-weight: 700;
color: #1e293b;
text-align: center;
}
/* Activity Timeline */
.activity-timeline {
display: flex;
flex-direction: column;
gap: 20px;
position: relative;
padding-left: 12px;
}
.activity-timeline::before {
content: '';
position: absolute;
left: 3px;
top: 8px;
bottom: 8px;
width: 2px;
background: #e2e8f0;
}
.activity-item {
display: flex;
gap: 16px;
position: relative;
}
.activity-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--brand-orange);
margin-top: 6px;
flex-shrink: 0;
z-index: 1;
}
.activity-text p {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: #334155;
}
.activity-text small {
font-size: 11px;
color: #94a3b8;
}
.empty-state {
text-align: center;
color: #94a3b8;
font-size: 13px;
padding: 20px 0;
}

View file

@ -1,7 +1,7 @@
import { Component, Show, createEffect, For, createSignal, onMount } from 'solid-js';
import { useNavigate, A, useSearchParams } from '@solidjs/router';
import { authState, logout, switchRole, bootstrapAuth, setMockRuntimeConfig } from '~/lib/auth';
import { shouldShowRoleSwitcher } from '~/lib/auth-flow';
import { authState, logout, switchRole, bootstrapAuth, setMockRuntimeConfig, isModuleLocked } from '~/lib/auth';
import { shouldShowRoleSwitcher, getRoleLabel } from '~/lib/auth-flow';
import {
getRoleTourStorageKey,
getWelcomeTourStorageKey,
@ -66,6 +66,10 @@ const IconWallet = () => (
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M20 12V8H6a2 2 0 0 1-2-2c0-1.1.9-2 2-2h12v4"/><path d="M4 6v12c0 1.1.9 2 2 2h14v-4"/><path d="M18 12a2 2 0 0 0-2 2c0 1.1.9 2 2 2h4v-4h-4z"/>
</svg>
);const IconLock = () => (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
);
// ── Module → nav item mapping ─────────────────────────────────────────────────
@ -115,6 +119,28 @@ const MODULE_NAV_MAP: Record<string, { label: string; href: string; icon: Compon
EXPLORE_NXTGAUGE: { label: 'Explore', href: '/dashboard/explore', icon: IconCompass, tourId: 'explore' },
};
/**
* Maps human-readable labels from the Admin panel to our internal navigation keys.
*/
function mapSidebarLabelToNavKey(label: string): string | null {
const normalized = String(label || '').toLowerCase().trim();
if (normalized.includes('dashboard')) return 'dashboard';
if (normalized.includes('profile')) return 'profile';
if (normalized.includes('lead') || normalized.includes('marketplace')) return 'MARKETPLACE';
if (normalized.includes('requirement')) return 'requirements';
if (normalized.includes('response')) return 'my_responses';
if (normalized.includes('job')) return 'jobs';
if (normalized.includes('portfolio')) return 'portfolio';
if (normalized.includes('credit') || normalized.includes('wallet')) return 'wallet';
if (normalized.includes('explore')) return 'EXPLORE_NXTGAUGE';
if (normalized.includes('verification')) return 'verification';
if (normalized.includes('setting')) return 'settings';
if (normalized.includes('support') || normalized.includes('help')) return 'support';
if (normalized.includes('notification')) return 'notifications';
if (normalized.includes('switch role')) return 'switch_role';
return null;
}
// ── Spotlight Tour Overlay ────────────────────────────────────────────────────
function SpotlightOverlay(props: {
@ -309,7 +335,7 @@ function SpotlightOverlay(props: {
export default function DashboardLayout(props: { children: any }) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const [switchingRole, setSwitchingRole] = createSignal(false);
const [tourKind, setTourKind] = createSignal<GuidedTourKind | null>(null);
const [tourStepIndex, setTourStepIndex] = createSignal(0);
@ -348,6 +374,38 @@ export default function DashboardLayout(props: { children: any }) {
const navItems = () => {
const role = String(activeRole() || rc()?.role || '').toUpperCase();
const roleConfig = rc()?.role_config;
// 1. If admin-defined sidebar_items exist, use them as priority
if (Array.isArray(roleConfig?.sidebar_items) && roleConfig.sidebar_items.length > 0) {
return roleConfig.sidebar_items
.map((label: string) => {
const key = mapSidebarLabelToNavKey(label);
const base = key ? MODULE_NAV_MAP[key] : null;
if (!base) return null;
const isLocked = isModuleLocked(key || '');
let finalItem = { ...base, label, isLocked }; // Use the admin-saved label
if (isLocked) {
finalItem.href = '#'; // Disable navigation
}
if (key === 'MARKETPLACE' && role === 'CUSTOMER') {
finalItem = { ...finalItem, label: 'Post Requirement', href: '/users/requirements/new' };
}
if (role === 'COMPANY') {
if (key === 'profile') finalItem.href = '/companies/profile';
if (key === 'support') finalItem.href = '/companies/support';
if (key === 'settings') finalItem.href = '/companies/settings';
}
return finalItem;
})
.filter((item: any): item is NonNullable<any> => Boolean(item));
}
// 2. Fallback to module-based logic
const moduleSet = new Set(
(Array.isArray(rc()?.enabled_modules) ? rc()!.enabled_modules : [])
.map((moduleKey) => String(moduleKey || '').toLowerCase()),
@ -362,20 +420,25 @@ export default function DashboardLayout(props: { children: any }) {
.map((m) => {
const base = MODULE_NAV_MAP[m];
if (!base) return null;
const isLocked = isModuleLocked(m);
const finalItem = { ...base, isLocked };
if (isLocked) {
finalItem.href = '#';
}
// Match Next.js role override: customer "leads" is "Post Requirement"
if (m === 'leads' && role === 'CUSTOMER') {
return { ...base, label: 'Post Requirement', href: '/users/requirements/new', tourId: 'requirements' };
return { ...finalItem, label: 'Post Requirement', href: isLocked ? '#' : '/users/requirements/new', tourId: 'requirements' };
}
// Match Next.js company route overrides.
if (role === 'COMPANY') {
if (m === 'profile') return { ...base, href: '/companies/profile' };
if (m === 'support') return { ...base, href: '/companies/support' };
if (m === 'settings') return { ...base, href: '/companies/settings' };
const prefix = isLocked ? '#' : '';
if (m === 'profile') return { ...finalItem, href: prefix + '/companies/profile' };
if (m === 'support') return { ...finalItem, href: prefix + '/companies/support' };
if (m === 'settings') return { ...finalItem, href: prefix + '/companies/settings' };
}
return base;
return finalItem;
})
.filter((item): item is NonNullable<typeof item> => Boolean(item))
.sort((left, right) => (left.order ?? 999) - (right.order ?? 999));
@ -452,7 +515,7 @@ export default function DashboardLayout(props: { children: any }) {
</div>
<div class="sidebar-role-badge">
<span class="role-badge">{rc()?.user?.active_role ?? 'Loading...'}</span>
<span class="role-badge">{getRoleLabel(rc()?.role ?? '')}</span>
</div>
<nav class="sidebar-nav">
@ -462,10 +525,17 @@ export default function DashboardLayout(props: { children: any }) {
href={item.href}
class="nav-item"
activeClass="nav-item-active"
classList={{ 'nav-item-locked': item.isLocked }}
data-tour-id={item.tourId}
onClick={(e) => { if (item.isLocked) e.preventDefault(); }}
>
<item.icon />
<span>{item.label}</span>
<Show when={item.isLocked}>
<div style={{ 'margin-left': 'auto', 'opacity': '0.6' }}>
<IconLock />
</div>
</Show>
</A>
)}
</For>
@ -485,22 +555,31 @@ export default function DashboardLayout(props: { children: any }) {
<div class="topbar-title">&nbsp;</div>
<div class="topbar-right">
<Show when={shouldShowRoleSwitcher(roleOptions())}>
<select
class="input"
style={{ width: '210px', 'font-size': '13px', padding: '8px 10px' }}
value={activeRole()}
disabled={switchingRole()}
onChange={(e) => handleRoleSwitch(e.currentTarget.value)}
title="Switch your dashboard view"
>
<For each={roleOptions()}>
{(role) => <option value={role}>{role.replaceAll('_', ' ')}</option>}
</For>
</select>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
<select
class="dashboard-role-select"
style={{ width: '180px', 'font-size': '13px', padding: '8px 10px', height: '36px' }}
value={activeRole()}
disabled={switchingRole()}
onChange={(e) => handleRoleSwitch(e.currentTarget.value)}
title="Switch your dashboard view"
>
<For each={roleOptions()}>
{(role) => <option value={role}>{role.replaceAll('_', ' ')}</option>}
</For>
</select>
<A
href="/choose-role"
class="topbar-icon-btn"
title="Add Another Role"
style={{ background: '#f8fafc', border: '1px solid #e2e8f0', color: '#64748b' }}
>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</A>
</div>
</Show>
<A href="/choose-role" class="btn btn-sm" style={{ 'text-decoration': 'none' }}>
Choose What You Need
</A>
<A href="/dashboard/notifications" class="topbar-icon-btn" title="Notifications">
<IconBell />
</A>
@ -512,6 +591,25 @@ export default function DashboardLayout(props: { children: any }) {
<main class="dashboard-content">
<Show when={rc()} fallback={<div class="loading-spinner">Loading...</div>}>
{/* Dynamic Tabs — rendered if config exists for current role/context */}
<Show when={rc()?.role_config?.tabs && rc()?.role_config.tabs.length > 0}>
<div class="dashboard-tabs">
<For each={rc()?.role_config.tabs}>
{(tab: string) => {
const isActive = () => searchParams.tab === tab || (!searchParams.tab && rc()?.role_config.tabs[0] === tab);
return (
<button
class="dashboard-tab"
classList={{ 'dashboard-tab--active': isActive() }}
onClick={() => setSearchParams({ tab })}
>
{tab}
</button>
);
}}
</For>
</div>
</Show>
{props.children}
</Show>
</main>

View file

@ -0,0 +1,181 @@
import { Show, For } from 'solid-js';
import { A } from '@solidjs/router';
// ── Widget Registry ──────────────────────────────────────────────────────────
export const WIDGET_COMPONENTS: Record<string, any> = {
kpi_summary: KPIWidget,
pending_approvals: PendingApprovalsWidget,
recent_activity: RecentActivityWidget,
quick_actions: QuickActionsWidget,
};
// ── KPI Widget ──────────────────────────────────────────────────────────────
export function KPIWidget(props: { role: string; data?: any }) {
const role = props.role.toUpperCase();
return (
<div class="kpi-grid">
<Show when={role === 'COMPANY'}>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--blue">💼</div>
<div class="kpi-content">
<div class="kpi-value">{props.data?.active_jobs ?? '—'}</div>
<div class="kpi-label">Active Jobs</div>
</div>
<A href="/dashboard/jobs" class="kpi-link">View Jobs </A>
</div>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--green">👥</div>
<div class="kpi-content">
<div class="kpi-value">{props.data?.total_applications ?? '—'}</div>
<div class="kpi-label">Total Applications</div>
</div>
</div>
</Show>
<Show when={role === 'CUSTOMER'}>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--blue">📋</div>
<div class="kpi-content">
<div class="kpi-value">{props.data?.active_requirements ?? '—'}</div>
<div class="kpi-label">Active Requirements</div>
</div>
<A href="/dashboard/requirements" class="kpi-link">View </A>
</div>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--green"></div>
<div class="kpi-content">
<div class="kpi-value">{props.data?.accepted_pros ?? '—'}</div>
<div class="kpi-label">Accepted Professionals</div>
</div>
</div>
</Show>
<Show when={role !== 'COMPANY' && role !== 'CUSTOMER' && role !== 'USER' && role !== 'JOB_SEEKER'}>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--blue">📩</div>
<div class="kpi-content">
<div class="kpi-value">{props.data?.responses_sent ?? '—'}</div>
<div class="kpi-label">Responses Sent</div>
</div>
<A href="/dashboard/requests" class="kpi-link">View </A>
</div>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--purple">🪙</div>
<div class="kpi-content">
<div class="kpi-value">{props.data?.wallet_balance ?? '—'}</div>
<div class="kpi-label">Tracecoins</div>
</div>
<A href="/dashboard/wallet" class="kpi-link">View Wallet </A>
</div>
</Show>
</div>
);
}
// ── Pending Approvals / Leads Widget ──────────────────────────────────────────
export function PendingApprovalsWidget(props: { role: string; data?: any[] }) {
return (
<div class="widget-card">
<div class="widget-header">
<h3>Pending Approvals</h3>
<span class="badge badge--orange">{props.data?.length ?? 0} New</span>
</div>
<div class="widget-content">
<Show
when={props.data && props.data.length > 0}
fallback={<p class="empty-state">No pending approvals at the moment.</p>}
>
<div class="list-container">
<For each={props.data}>
{(item) => (
<div class="list-item">
<div class="list-item-info">
<strong>{item.title}</strong>
<span>{item.subtitle}</span>
</div>
<A href={item.link} class="btn btn-sm btn-outline">Review</A>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
);
}
// ── Recent Activity Widget ────────────────────────────────────────────────────
export function RecentActivityWidget(props: { data?: any[] }) {
return (
<div class="widget-card">
<div class="widget-header">
<h3>Recent Activity</h3>
</div>
<div class="widget-content">
<div class="activity-timeline">
<Show
when={props.data && props.data.length > 0}
fallback={<p class="empty-state">Your recent activities will appear here.</p>}
>
<For each={props.data}>
{(activity) => (
<div class="activity-item">
<span class="activity-dot"></span>
<div class="activity-text">
<p>{activity.message}</p>
<small>{activity.time}</small>
</div>
</div>
)}
</For>
</Show>
</div>
</div>
</div>
);
}
// ── Quick Actions Widget ──────────────────────────────────────────────────────
export function QuickActionsWidget(props: { role: string }) {
const actions = () => {
const r = props.role.toUpperCase();
if (r === 'CUSTOMER') return [
{ label: 'Post Requirement', href: '/users/requirements/new', icon: '' },
{ label: 'Browse Professionals', href: '/dashboard/explore', icon: '🔍' },
];
if (r === 'COMPANY') return [
{ label: 'Post a Job', href: '/companies/job-postings/new', icon: '📝' },
{ label: 'Search Candidates', href: '/dashboard/explore', icon: '👤' },
];
return [
{ label: 'Browse Leads', href: '/dashboard/requests', icon: '🎯' },
{ label: 'Update Portfolio', href: '/users/professional/portfolio', icon: '🖼️' },
];
};
return (
<div class="widget-card">
<div class="widget-header">
<h3>Quick Actions</h3>
</div>
<div class="widget-content">
<div class="quick-actions-grid">
<For each={actions()}>
{(action) => (
<A href={action.href} class="quick-action-btn">
<span class="action-icon">{action.icon}</span>
<span>{action.label}</span>
</A>
)}
</For>
</div>
</div>
</div>
);
}

View file

@ -5,6 +5,15 @@ export function normalizeSafeRedirect(value: string | null | undefined): string
return value.startsWith('/') ? value : null;
}
export function getRoleLabel(roleKey: string): string {
const k = String(roleKey || '').toUpperCase();
if (k === 'CUSTOMER' || k === 'SERVICE_SEEKER') return 'Customer';
if (k === 'COMPANY') return 'Company';
if (k === 'JOB_SEEKER' || k === 'JOBSEEKER') return 'Job Seeker';
return k.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
export function resolvePostLoginTarget(params: {
redirect?: string | null;
intent?: CanonicalIntent;
@ -13,9 +22,7 @@ export function resolvePostLoginTarget(params: {
const safeRedirect = normalizeSafeRedirect(params.redirect);
if (safeRedirect) return safeRedirect;
const resolvedIntent = params.intent || params.storedIntent || null;
if (resolvedIntent) return intentToOnboardingPath(resolvedIntent);
// We are removing the mandatory onboarding flow, so we go straight to dashboard.
return '/dashboard';
}

View file

@ -18,9 +18,42 @@ export interface RuntimeConfig {
id: string;
full_name: string;
email: string;
phone?: string;
roles: string[];
active_role: string;
};
role_config?: any;
profile?: any;
dashboard_data?: any;
verification_status?: {
is_verified: boolean;
missing_requirements: string[];
completion_percentage: number;
};
}
/**
* Maps a role key (e.g. MAKEUP_ARTIST) to its primary microservice API prefix (e.g. /api/makeup-artists).
* This ensures the frontend stays 100% runtime-config driven and avoids hardcoding.
*/
export function getRoleApiPath(role?: string): string {
const key = String(role || '').toUpperCase().trim();
if (key === 'PHOTOGRAPHER') return '/api/photographers';
if (key === 'MAKEUP_ARTIST') return '/api/makeup-artists';
if (key === 'TUTOR') return '/api/tutors';
if (key === 'DEVELOPER') return '/api/developers';
if (key === 'VIDEO_EDITOR') return '/api/video-editors';
if (key === 'GRAPHIC_DESIGNER') return '/api/graphic-designers';
if (key === 'SOCIAL_MEDIA_MANAGER') return '/api/social-media-managers';
if (key === 'FITNESS_TRAINER') return '/api/fitness-trainers';
if (key === 'CATERING_SERVICES') return '/api/catering-services';
// Generic fallbacks
if (key === 'COMPANY') return '/api/companies';
if (key === 'CUSTOMER' || key === 'SERVICE_SEEKER') return '/api/customers';
if (key === 'JOB_SEEKER') return '/api/job-seekers';
return '/api/users';
}
export interface AuthState {
@ -159,6 +192,30 @@ export function hasPermission(key: string): boolean {
return authState().runtime_config?.permissions[key] ?? false;
}
/**
* Checks if a dashboard module should be locked based on verification status.
* This ensures the UI is 100% runtime-config driven.
*/
export function isModuleLocked(moduleKey: string): boolean {
const rc = authState().runtime_config;
if (!rc) return false;
// Settings, Support, Profile, and Explore are NEVER locked
const alwaysOpen = ['profile', 'settings', 'support', 'notifications', 'EXPLORE_NXTGAUGE', 'dashboard', 'verification'];
if (alwaysOpen.includes(moduleKey)) return false;
// If already verified, nothing is locked
if (rc.verification_status?.is_verified) return false;
// In Phase 6, we lock Leads, Jobs, and Marketplace if verification is pending
const gatedModules = ['leads', 'MARKETPLACE', 'requirements', 'jobs', 'portfolio', 'wallet', 'tracecoins', 'applications', 'job_postings'];
if (gatedModules.includes(moduleKey)) {
return true; // Lock gated modules if not verified
}
return false;
}
export function getAuthHeader(): Record<string, string> {
const token = authState().access_token;
return token ? { Authorization: `Bearer ${token}` } : {};

View file

@ -1,28 +0,0 @@
import { useLocation } from '@solidjs/router';
import GenericAdminModulePage from '~/components/admin/GenericAdminModulePage';
import { ActionButton, PageHeader, SectionCard } from '~/components/admin/AdminUi';
import { getAdminModuleByPath } from '~/lib/admin/module-config';
export default function AdminFallbackRoutePage() {
const location = useLocation();
const module = () => getAdminModuleByPath(location.pathname);
if (module()) {
return <GenericAdminModulePage module={module()!} />;
}
return (
<div class="space-y-5">
<PageHeader
title="Unknown Admin Route"
subtitle={`No module is registered for ${location.pathname}. Add it to admin module config to activate.`}
actions={<ActionButton tone="primary">Go To Dashboard</ActionButton>}
/>
<SectionCard title="Registry Required" subtitle="This route is outside the declared admin module contract.">
<p class="text-sm text-slate-600">
The current path is not part of the configured admin modules. Register it in the module config and map a page component.
</p>
</SectionCard>
</div>
);
}

View file

@ -1,143 +0,0 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, MetricCards, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { bulkApproval, listApprovalCases } from '~/lib/admin/client';
import type { ApprovalCase } from '~/lib/admin/types';
const toneByStatus: Record<ApprovalCase['status'], 'neutral' | 'warning' | 'positive' | 'critical' | 'info'> = {
PENDING_APPROVAL: 'warning',
IN_REVIEW: 'info',
APPROVED: 'positive',
REJECTED: 'critical',
ON_HOLD: 'warning',
ESCALATED: 'critical',
};
export default function ApprovalManagementPage() {
const [tab, setTab] = createSignal<'queue' | 'rules' | 'preview'>('queue');
const [query, setQuery] = createSignal('');
const [rows, setRows] = createSignal<ApprovalCase[]>([]);
const [selected, setSelected] = createSignal<string[]>([]);
const [error, setError] = createSignal('');
const load = async () => {
try {
setError('');
setRows(await listApprovalCases({ q: query() }));
} catch (err: any) {
setError(String(err?.message || 'Failed to load approval cases.'));
}
};
onMount(() => void load());
const metrics = createMemo(() => {
const data = rows();
return [
{ label: 'Total Pending', value: String(data.filter((d) => d.status === 'PENDING_APPROVAL').length || 0) },
{ label: 'Approved Today', value: String(data.filter((d) => d.status === 'APPROVED').length || 0), tone: 'positive' as const },
{ label: 'Rejected Today', value: String(data.filter((d) => d.status === 'REJECTED').length || 0), tone: 'critical' as const },
{ label: 'On Hold Cases', value: String(data.filter((d) => d.status === 'ON_HOLD').length || 0), tone: 'warning' as const },
{ label: 'Escalated Cases', value: String(data.filter((d) => d.status === 'ESCALATED').length || 0), tone: 'critical' as const },
];
});
const filtered = createMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return rows();
return rows().filter((r) => r.id.toLowerCase().includes(q) || r.applicantName.toLowerCase().includes(q));
});
const toggle = (id: string, checked: boolean) => setSelected((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((x) => x !== id)));
const runBulk = async (action: string) => {
if (selected().length === 0) return;
try {
setError('');
await bulkApproval(selected(), action);
setSelected([]);
await load();
} catch (err: any) {
setError(String(err?.message || 'Bulk action failed.'));
}
};
return (
<div class="space-y-5">
<PageHeader
title="Approval Management"
subtitle="Review and approve verified submissions with explicit final decision states."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'queue', label: 'Approval Queue' }, { key: 'rules', label: 'Approval Rules' }, { key: 'preview', label: 'User Preview' }]} />}
/>
<MetricCards items={metrics()} />
{error() ? <p class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error()}</p> : null}
{tab() === 'queue' ? (
<SectionCard
title="Approval Cases"
subtitle="Final governance queue fed from verification outcomes."
actions={
<>
<ActionButton>Export Queue</ActionButton>
<ActionButton onClick={() => void runBulk('hold')}>Put On Hold</ActionButton>
<ActionButton tone="primary" onClick={() => void runBulk('approve')}>Approve Selected</ActionButton>
</>
}
>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(v) => {
setQuery(v);
void load();
}}
right={
<>
<ActionButton onClick={() => void runBulk('reject')}>Reject</ActionButton>
<ActionButton onClick={() => void runBulk('escalate')}>Escalate</ActionButton>
</>
}
/>
<DataTable
headers={['', 'Approval ID', 'Applicant Name', 'Approval Type', 'User Type', 'Submitted Date', 'Verification', 'Approval Status', 'Priority', 'Actions']}
rows={filtered().map((row) => [
<input type="checkbox" checked={selected().includes(row.id)} onInput={(e) => toggle(row.id, e.currentTarget.checked)} />,
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.applicantName}</span>,
<span>{row.approvalType}</span>,
<span>{row.userType}</span>,
<span class="text-xs text-slate-500">{row.submittedAt}</span>,
<StatusBadge label={row.verificationStatus} tone={row.verificationStatus === 'VERIFIED' ? 'positive' : 'critical'} />,
<StatusBadge label={row.status} tone={toneByStatus[row.status]} />,
<StatusBadge label={row.priority} tone={row.priority === 'LOW' ? 'neutral' : row.priority === 'MEDIUM' ? 'warning' : 'critical'} />,
<ActionButton tone="ghost">Open</ActionButton>,
])}
/>
</div>
</SectionCard>
) : tab() === 'rules' ? (
<SectionCard title="Approval Rules" subtitle="Configure thresholds for auto-approval, manual review, and escalation.">
<DataTable
headers={['Rule', 'Scope', 'Policy', 'Owner', 'Actions']}
rows={[
['Verified profiles with low-risk score can auto-approve', 'Profile', 'AUTO_APPROVE', 'Governance', <ActionButton>Edit</ActionButton>],
['Critical flagged verifications require escalation', 'All', 'ESCALATE', 'Compliance', <ActionButton>Edit</ActionButton>],
['Rejected verification cannot move to approved state', 'All', 'HARD_BLOCK', 'Platform', <ActionButton>Edit</ActionButton>],
]}
/>
</SectionCard>
) : (
<SectionCard title="User Preview" subtitle="Preview user-facing approval outcomes and guidance.">
<div class="rounded-xl border border-[#e4e9f2] bg-[#f8fbff] p-4">
<h3 class="text-base font-semibold text-[#050026]">Approval Status Timeline</h3>
<ol class="mt-3 space-y-2 text-sm text-slate-600">
<li>1. Case enters queue after verification outcome.</li>
<li>2. Approval team decides approve, reject, hold, or escalate.</li>
<li>3. Approved users receive access destination and activation.</li>
<li>4. Rejected/escalated users receive remediation guidance.</li>
</ol>
</div>
</SectionCard>
)}
</div>
);
}

View file

@ -1 +0,0 @@
export { default } from './approval-management';

View file

@ -1,115 +0,0 @@
import { createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { createModuleRecord, listModuleRecords, updateModuleRecord } from '~/lib/admin/client';
import type { CrudRecord } from '~/lib/admin/types';
const seedRoles: CrudRecord[] = [
{ id: 'COMPANY', name: 'Company', status: 'ACTIVE', updatedAt: new Date().toISOString() },
{ id: 'JOB_SEEKER', name: 'Job Seeker', status: 'ACTIVE', updatedAt: new Date().toISOString() },
{ id: 'PHOTOGRAPHER', name: 'Photographer', status: 'ACTIVE', updatedAt: new Date().toISOString() },
{ id: 'CUSTOMER', name: 'Customer', status: 'INACTIVE', updatedAt: new Date().toISOString() },
];
export default function ExternalRoleManagementPage() {
const [tab, setTab] = createSignal<'view' | 'create' | 'inspector'>('view');
const [query, setQuery] = createSignal('');
const [roles, setRoles] = createSignal<CrudRecord[]>(seedRoles);
const [roleKey, setRoleKey] = createSignal('');
const [displayName, setDisplayName] = createSignal('');
const [vertical, setVertical] = createSignal('');
const [schema, setSchema] = createSignal('');
const load = async () => setRoles(await listModuleRecords('external-role-management', { q: query() }));
onMount(() => void load());
return (
<div class="space-y-5">
<PageHeader
title="External Role Management"
subtitle="Manage canonical external runtime roles, modules, and onboarding schema assignment."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'view', label: 'View Roles' }, { key: 'create', label: 'Create Role' }, { key: 'inspector', label: 'Inspector' }]} />}
/>
<SectionCard
title={tab() === 'create' ? 'Create External Role' : tab() === 'inspector' ? 'Role UI Inspector' : 'Published External Roles'}
subtitle={tab() === 'view' ? 'Only canonical external runtime roles are shown on this surface.' : 'Manage external role shape and downstream UI policies.'}
actions={
<>
<ActionButton>Export</ActionButton>
<ActionButton tone="primary">Create External Role</ActionButton>
</>
}
>
{tab() === 'view' ? (
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(value) => {
setQuery(value);
void load();
}}
right={<ActionButton>Filter</ActionButton>}
/>
<DataTable
headers={['Role', 'Type', 'Modules', 'Schema', 'Status', 'Actions']}
rows={roles()
.filter((r) => {
const q = query().trim().toLowerCase();
if (!q) return true;
return r.name.toLowerCase().includes(q) || r.id.toLowerCase().includes(q);
})
.map((role) => [
<div>
<p class="font-medium text-[#050026]">{role.name}</p>
<p class="text-xs text-slate-500">{role.id}</p>
</div>,
<span>{vertical() || 'EXTERNAL'}</span>,
<span>{Math.max(4, (role.name.length % 8) + 3)}</span>,
<span>{schema() || 'default-v1'}</span>,
<StatusBadge label={role.status} tone={role.status === 'ACTIVE' ? 'positive' : 'warning'} />,
<div class="flex gap-2">
<ActionButton tone="ghost">View</ActionButton>
<ActionButton
onClick={() => {
const next = role.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
void updateModuleRecord('external-role-management', role.id, { status: next }).then(() => void load());
}}
>
Toggle
</ActionButton>
</div>,
])}
/>
</div>
) : tab() === 'create' ? (
<form
class="grid gap-4 rounded-xl border border-[#e5eaf3] bg-[#f9fbff] p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
const key = roleKey().trim() || displayName().trim() || 'NEW_ROLE';
const name = displayName().trim() || key;
void createModuleRecord('external-role-management', { id: key, name, status: 'ACTIVE' }).then(() => {
setRoleKey('');
setDisplayName('');
setVertical('');
setSchema('');
setTab('view');
void load();
});
}}
>
<label class="text-sm font-medium text-slate-700">Role Key<input value={roleKey()} onInput={(e) => setRoleKey(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" /></label>
<label class="text-sm font-medium text-slate-700">Display Name<input value={displayName()} onInput={(e) => setDisplayName(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" /></label>
<label class="text-sm font-medium text-slate-700">Vertical<input value={vertical()} onInput={(e) => setVertical(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" /></label>
<label class="text-sm font-medium text-slate-700">Onboarding Schema<input value={schema()} onInput={(e) => setSchema(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" /></label>
<div class="md:col-span-2 flex justify-end gap-2"><ActionButton>Cancel</ActionButton><ActionButton type="submit" tone="primary">Save Role</ActionButton></div>
</form>
) : (
<div class="rounded-xl border border-dashed border-[#d9e1ef] bg-[#fbfcff] p-6 text-sm text-slate-600">
Role inspector supports schema-version checks, module visibility, and default landing-route policies.
</div>
)}
</SectionCard>
</div>
);
}

View file

@ -1,133 +0,0 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { createModuleRecord, listModuleRecords, updateModuleRecord } from '~/lib/admin/client';
import type { CrudRecord } from '~/lib/admin/types';
const permissions = [
'Department Management',
'Designation Management',
'Internal Role Management',
'External Role Management',
'Verification Management',
'Approval Management',
'Users Management',
'Company Management',
'Candidate Management',
];
export default function InternalRoleManagementPage() {
const [tab, setTab] = createSignal<'roles' | 'create' | 'permissions'>('roles');
const [query, setQuery] = createSignal('');
const [roleRows, setRoleRows] = createSignal<CrudRecord[]>([]);
const [roleName, setRoleName] = createSignal('');
const [roleCode, setRoleCode] = createSignal('');
const [roleDesc, setRoleDesc] = createSignal('');
const load = async () => setRoleRows(await listModuleRecords('internal-role-management', { q: query() }));
onMount(() => void load());
const filtered = createMemo(() => {
const q = query().toLowerCase().trim();
if (!q) return roleRows();
return roleRows().filter((r) => r.id.toLowerCase().includes(q) || r.name.toLowerCase().includes(q));
});
return (
<div class="space-y-5">
<PageHeader
title="Internal Role Management"
subtitle="Manage internal admin roles, module assignments, and permission policies."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'roles', label: 'View Roles' }, { key: 'create', label: 'Create Role' }, { key: 'permissions', label: 'Permission Matrix' }]} />}
/>
{tab() === 'roles' ? (
<SectionCard title="Roles" subtitle="Internal permission-bearing identities" actions={<ActionButton tone="primary">Create Internal Role</ActionButton>}>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(value) => {
setQuery(value);
void load();
}}
right={<ActionButton>Filter</ActionButton>}
/>
<DataTable
headers={['Role ID', 'Name', 'Modules', 'Assigned Users', 'Status', 'Actions']}
rows={filtered().map((row) => [
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.name}</span>,
<span>{Math.max(4, (row.name.length % 12) + 4)}</span>,
<span>{Math.max(2, (row.id.length % 9) + 2)}</span>,
<StatusBadge label={row.status} tone={row.status === 'ACTIVE' ? 'positive' : 'warning'} />,
<div class="flex gap-2">
<ActionButton
onClick={() => {
const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
void updateModuleRecord('internal-role-management', row.id, { status: next }).then(() => void load());
}}
>
Toggle
</ActionButton>
<ActionButton tone="ghost">View</ActionButton>
</div>,
])}
/>
</div>
</SectionCard>
) : null}
{tab() === 'create' ? (
<SectionCard title="Create Internal Role" subtitle="Define role metadata and module access rules.">
<form
class="grid gap-4 rounded-xl border border-[#e5eaf3] bg-[#f9fbff] p-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
const name = roleName().trim() || roleCode().trim() || 'New Internal Role';
void createModuleRecord('internal-role-management', { name, status: 'ACTIVE' }).then(() => {
setRoleName('');
setRoleCode('');
setRoleDesc('');
setTab('roles');
void load();
});
}}
>
<label class="text-sm font-medium text-slate-700">
Role Name
<input value={roleName()} onInput={(e) => setRoleName(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<label class="text-sm font-medium text-slate-700">
Role Code
<input value={roleCode()} onInput={(e) => setRoleCode(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" />
</label>
<label class="text-sm font-medium text-slate-700 md:col-span-2">
Description
<textarea value={roleDesc()} onInput={(e) => setRoleDesc(e.currentTarget.value)} class="mt-1 block w-full rounded-lg border border-[#d8dfec] bg-white px-3 py-2 text-sm outline-none focus:border-[#fd6116]" rows={3} />
</label>
<div class="md:col-span-2 flex justify-end gap-2">
<ActionButton>Cancel</ActionButton>
<ActionButton type="submit" tone="primary">
Save Role
</ActionButton>
</div>
</form>
</SectionCard>
) : null}
{tab() === 'permissions' ? (
<SectionCard title="Permission Matrix" subtitle="Grant create/read/update/delete operations per module.">
<DataTable
headers={['Module', 'Create', 'Read', 'Update', 'Delete']}
rows={permissions.map((module, idx) => [
<span class="font-medium text-[#050026]">{module}</span>,
<input type="checkbox" checked={idx % 2 === 0} />,
<input type="checkbox" checked />,
<input type="checkbox" checked={idx % 3 !== 0} />,
<input type="checkbox" checked={idx % 4 === 0} />,
])}
/>
</SectionCard>
) : null}
</div>
);
}

View file

@ -1,142 +0,0 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { ActionButton, DataTable, MetricCards, PageHeader, SearchFilters, SectionCard, StatusBadge, Tabs } from '~/components/admin/AdminUi';
import { bulkVerification, listVerificationCases } from '~/lib/admin/client';
import type { VerificationCase } from '~/lib/admin/types';
const toneByStatus: Record<VerificationCase['status'], 'neutral' | 'warning' | 'positive' | 'critical' | 'info'> = {
PENDING: 'warning',
IN_REVIEW: 'info',
VERIFIED: 'positive',
REJECTED: 'critical',
FLAGGED: 'critical',
};
export default function VerificationManagementPage() {
const [tab, setTab] = createSignal<'queue' | 'rules' | 'preview'>('queue');
const [query, setQuery] = createSignal('');
const [rows, setRows] = createSignal<VerificationCase[]>([]);
const [selected, setSelected] = createSignal<string[]>([]);
const [error, setError] = createSignal('');
const load = async () => {
try {
setError('');
setRows(await listVerificationCases({ q: query() }));
} catch (err: any) {
setError(String(err?.message || 'Failed to load verification cases.'));
}
};
onMount(() => void load());
const metrics = createMemo(() => {
const data = rows();
return [
{ label: 'Total Pending', value: String(data.filter((d) => d.status === 'PENDING').length || 0) },
{ label: 'Identity Verification', value: String(data.filter((d) => d.verificationType.includes('Identity')).length || 0), tone: 'info' as const },
{ label: 'Business Verification', value: String(data.filter((d) => d.verificationType.includes('Business')).length || 0), tone: 'warning' as const },
{ label: 'Verified Today', value: String(data.filter((d) => d.status === 'VERIFIED').length || 0), tone: 'positive' as const },
{ label: 'Flagged Cases', value: String(data.filter((d) => d.status === 'FLAGGED').length || 0), tone: 'critical' as const },
];
});
const filtered = createMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return rows();
return rows().filter((r) => r.id.toLowerCase().includes(q) || r.applicantName.toLowerCase().includes(q));
});
const toggle = (id: string, checked: boolean) => setSelected((prev) => (checked ? [...new Set([...prev, id])] : prev.filter((x) => x !== id)));
const runBulk = async (action: string) => {
if (selected().length === 0) return;
try {
setError('');
await bulkVerification(selected(), action);
setSelected([]);
await load();
} catch (err: any) {
setError(String(err?.message || 'Bulk action failed.'));
}
};
return (
<div class="space-y-5">
<PageHeader
title="Verification Management"
subtitle="Review and verify user submissions before they enter final approval queue."
actions={<Tabs value={tab()} onChange={setTab} items={[{ key: 'queue', label: 'Verification Queue' }, { key: 'rules', label: 'Verification Rules' }, { key: 'preview', label: 'User Preview' }]} />}
/>
<MetricCards items={metrics()} />
{error() ? <p class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error()}</p> : null}
{tab() === 'queue' ? (
<SectionCard
title="Verification Cases"
subtitle="Hybrid data mode enabled. Bulk actions update downstream approval eligibility."
actions={
<>
<ActionButton>Export Queue</ActionButton>
<ActionButton onClick={() => void runBulk('mark_in_review')}>Mark In Review</ActionButton>
<ActionButton tone="primary" onClick={() => void runBulk('approve_for_approval')}>Approve For Approval</ActionButton>
</>
}
>
<div class="space-y-3">
<SearchFilters
query={query()}
onQuery={(v) => {
setQuery(v);
void load();
}}
right={
<>
<ActionButton onClick={() => void runBulk('flag')}>Flag</ActionButton>
<ActionButton onClick={() => void runBulk('reject')}>Reject</ActionButton>
</>
}
/>
<DataTable
headers={['', 'Verification ID', 'Applicant Name', 'User Type', 'Verification Type', 'Submitted Date', 'Documents', 'Status', 'Priority', 'Actions']}
rows={filtered().map((row) => [
<input type="checkbox" checked={selected().includes(row.id)} onInput={(e) => toggle(row.id, e.currentTarget.checked)} />,
<span class="font-medium text-[#050026]">{row.id}</span>,
<span>{row.applicantName}</span>,
<span>{row.userType}</span>,
<span>{row.verificationType}</span>,
<span class="text-xs text-slate-500">{row.submittedAt}</span>,
<span>{row.documents}</span>,
<StatusBadge label={row.status} tone={toneByStatus[row.status]} />,
<StatusBadge label={row.priority} tone={row.priority === 'LOW' ? 'neutral' : row.priority === 'MEDIUM' ? 'warning' : 'critical'} />,
<ActionButton tone="ghost">Open</ActionButton>,
])}
/>
</div>
</SectionCard>
) : tab() === 'rules' ? (
<SectionCard title="Verification Rules" subtitle="Configure rule severity and evidence requirements.">
<DataTable
headers={['Rule', 'Type', 'Severity', 'Evidence Required', 'Actions']}
rows={[
['Government ID must be valid and unexpired', 'Identity', <StatusBadge label="HIGH" tone="critical" />, 'Yes', <ActionButton>Edit</ActionButton>],
['Business GSTIN and legal docs must match', 'Business', <StatusBadge label="HIGH" tone="critical" />, 'Yes', <ActionButton>Edit</ActionButton>],
['Portfolio sample quality threshold', 'Professional', <StatusBadge label="MEDIUM" tone="warning" />, 'Optional', <ActionButton>Edit</ActionButton>],
]}
/>
</SectionCard>
) : (
<SectionCard title="User Preview" subtitle="Preview user-facing verification state messaging.">
<div class="rounded-xl border border-[#e4e9f2] bg-[#f8fbff] p-4">
<h3 class="text-base font-semibold text-[#050026]">Verification Status Timeline</h3>
<ol class="mt-3 space-y-2 text-sm text-slate-600">
<li>1. Submitted: user uploads required documents.</li>
<li>2. In Review: verification team validates authenticity.</li>
<li>3. Outcome: verified, rejected, or flagged.</li>
<li>4. Eligible outcomes move to Approval Queue automatically.</li>
</ol>
</div>
</SectionCard>
)}
</div>
);
}

View file

@ -1 +0,0 @@
export { default } from './verification-management';

View file

@ -1,6 +1,6 @@
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createMemo, createSignal, onMount } from 'solid-js';
import { intentToOnboardingPath, normalizeIntent, saveCanonicalIntent } from '~/lib/auth-intent';
import { normalizeIntent, saveCanonicalIntent } from '~/lib/auth-intent';
import PublicBackground from '~/components/PublicBackground';
import PublicHeader from '~/components/PublicHeader';
import CaptchaCanvas from '~/components/CaptchaCanvas';
@ -49,30 +49,18 @@ export default function RegisterPage() {
const navigate = useNavigate();
const [search] = useSearchParams();
const intentParam = normalizeIntent(search.intent || search.intentRole);
const redirectParam = search.redirect;
const safeRedirect = redirectParam && redirectParam.startsWith('/') ? redirectParam : null;
const professionalRole = normalizeProfessionalRole(search.profession || search.role || null);
const intentParam = normalizeIntent(String(search.intent || search.intentRole || ''));
const redirectParam = String(search.redirect || '');
const safeRedirect = redirectParam.startsWith('/') ? redirectParam : null;
const professionalRole = normalizeProfessionalRole(String(search.profession || search.role || ''));
const resolvedIntent = intentParam;
const onboardingTarget = createMemo(() => {
if (!resolvedIntent) return '/dashboard';
const base = intentToOnboardingPath(resolvedIntent);
if (resolvedIntent !== 'professional' || !professionalRole) return base;
return `${base}?profession=${encodeURIComponent(professionalRole)}`;
// We are removing the mandatory onboarding flow, so we go straight to dashboard.
return '/dashboard';
});
const resolvedRedirect = createMemo(() => {
if (!safeRedirect) return onboardingTarget();
if (
resolvedIntent === 'professional' &&
professionalRole &&
safeRedirect.startsWith('/users/onboarding/professional') &&
!safeRedirect.includes('profession=')
) {
return `${safeRedirect}?profession=${encodeURIComponent(professionalRole)}`;
}
return safeRedirect;
return safeRedirect || '/dashboard';
});
const [firstName, setFirstName] = createSignal('');
@ -450,6 +438,9 @@ export default function RegisterPage() {
</div>
<div class="field" style={{ 'margin-top': '16px' }}>
<A href="/dashboard/explore" class="btn btn-sm" style={{ 'text-decoration': 'none' }}>
Explore NextGange
</A>
<label class="auth-checkbox-wrapper">
<input
type="checkbox"

View file

@ -1,15 +1,132 @@
import { A } from '@solidjs/router';
import { createMemo, createSignal, Show, For } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { authState, switchRole, fetchRuntimeConfig } from '~/lib/auth';
import { getRoleLabel } from '~/lib/auth-flow';
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 ExploreNextGange() {
const navigate = useNavigate();
const [loading, setLoading] = createSignal<string | null>(null);
const [error, setError] = createSignal('');
const registeredRoles = createMemo(() => new Set(authState().runtime_config?.user?.roles ?? []));
const activeRole = () => authState().runtime_config?.role;
async function handleAction(roleKey: string, alreadyRegistered: boolean) {
setLoading(roleKey);
setError('');
try {
if (alreadyRegistered) {
// Just switch to it
if (roleKey === activeRole()) {
setError('You are already viewing this dashboard.');
return;
}
await switchRole(roleKey);
navigate('/dashboard', { replace: true });
return;
}
// Register for the new role
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) {
// After registration, stay in dashboard but fetch new config
await fetchRuntimeConfig();
// Switch to the new role automatically
await switchRole(roleKey);
navigate('/dashboard', { 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(null);
}
}
export default function DashboardExplorePage() {
return (
<section class="dashboard-card">
<h1>Choose What You Want to Do</h1>
<p class="dashboard-muted">
Tell us how you want to use Nxtgauge next. Each path has a quick onboarding and review step.
</p>
<p>
Go to <A href="/choose-role">Choose Your Path</A> to continue. If you unlock more than one path, you can switch from the dashboard top bar.
</p>
</section>
<div style={{ 'max-width': '1000px' }}>
<div style={{ 'margin-bottom': '32px' }}>
<h1 style={{ margin: 0, 'font-size': '24px', 'font-weight': '800' }}>Explore NextGange</h1>
<p style={{ margin: '6px 0 0', color: '#64748b', 'font-size': '15px' }}>
Discover new opportunities by registering for additional roles. Switch between them anytime.
</p>
</div>
<Show when={error()}>
<div class="error-banner" style={{ 'margin-bottom': '20px' }}>{error()}</div>
</Show>
<div style={{
display: 'grid',
'grid-template-columns': 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '20px'
}}>
<For each={ALL_ROLES}>
{(role) => {
const isRegistered = () => registeredRoles().has(role.key);
const isActive = () => activeRole() === role.key;
return (
<div
class="table-card"
style={{
padding: '24px',
display: 'flex',
'flex-direction': 'column',
border: isActive() ? '2px solid #fd6116' : '1px solid rgba(16, 11, 47, 0.08)',
background: isActive() ? '#fffaf8' : '#fff'
}}
>
<div style={{ 'font-size': '32px', 'margin-bottom': '12px' }}>{role.icon}</div>
<h3 style={{ margin: '0 0 8px', 'font-size': '18px', 'font-weight': '700' }}>{role.label}</h3>
<p style={{ margin: '0 0 20px', color: '#64748b', 'font-size': '13px', flex: 1 }}>{role.desc}</p>
<button
class="btn"
classList={{
'btn-primary': !isRegistered(),
'btn-outline': isRegistered() && !isActive()
}}
disabled={!!loading() || isActive()}
onClick={() => handleAction(role.key, isRegistered())}
style={{ width: '100%' }}
>
<Show when={loading() === role.key} fallback={
isActive() ? 'Currently Active' : (isRegistered() ? `Switch to ${role.label}` : `Start as ${role.label}`)
}>
Working...
</Show>
</button>
</div>
);
}}
</For>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { Show, createResource, For } from 'solid-js';
import { A } from '@solidjs/router';
import { authState, getAuthHeader } from '~/lib/auth';
import { WIDGET_COMPONENTS } from '~/components/dashboard/DashboardWidgets';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
@ -15,54 +16,79 @@ export default function DashboardIndex() {
<p class="page-subtitle">Here's what's happening in your dashboard.</p>
</div>
{/* Pending verification banner */}
<Show when={rc()?.onboarding_status === 'PENDING_REVIEW'}>
<div class="status-banner status-banner--warning">
<span></span>
<div>
<strong>Verification Pending</strong>
<p>Your documents are being reviewed. This usually takes 23 business days.</p>
</div>
<A href="/pending" class="btn btn-outline btn-sm">Check Status</A>
</div>
</Show>
<Show when={rc()?.onboarding_status === 'DOCUMENTS_REQUESTED'}>
<div class="status-banner status-banner--danger">
<span>📄</span>
<div>
<strong>Additional Documents Required</strong>
<p>Admin has requested more information. Please check your pending status.</p>
</div>
<A href="/pending" class="btn btn-primary btn-sm">Upload Documents</A>
</div>
</Show>
{/* KPI cards — rendered based on role via runtimeConfig */}
<div class="kpi-grid" data-tour-id="kpi-grid">
<Show when={role() === 'USER'}>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--blue">🧭</div>
<div class="kpi-content">
<div class="kpi-value">Get Started</div>
<div class="kpi-label">Choose a role to unlock role-based onboarding and modules</div>
{/* Verification Status Overview */}
<Show when={!rc()?.verification_status?.is_verified}>
<div class="table-card" style={{ padding: '24px', 'margin-bottom': '24px', border: '1px solid #fee2e2', background: '#fffafb' }}>
<div style={{ display: 'flex', 'align-items': 'center', 'justify-content': 'space-between', 'margin-bottom': '16px' }}>
<div>
<h2 style={{ margin: 0, 'font-size': '18px', 'font-weight': '800', color: '#991b1b' }}>Complete Your Verification</h2>
<p style={{ margin: '4px 0 0', color: '#7f1d1d', 'font-size': '14px', opacity: 0.8 }}>
Unlock all features by completing the requirements below.
</p>
</div>
<div style={{ 'text-align': 'right' }}>
<div style={{ 'font-size': '24px', 'font-weight': '800', color: '#991b1b' }}>{rc()?.verification_status?.completion_percentage ?? 0}%</div>
<div style={{ 'font-size': '11px', 'font-weight': '700', 'text-transform': 'uppercase', color: '#b91c1c' }}>Complete</div>
</div>
<A href="/dashboard/explore" class="kpi-link">Explore Nxtgauge </A>
</div>
</Show>
<Show when={role() === 'COMPANY'}>
<CompanyKPIs />
</Show>
<Show when={role() === 'JOB_SEEKER'}>
<JobSeekerKPIs />
</Show>
<Show when={role() === 'CUSTOMER'}>
<CustomerKPIs />
</Show>
<Show when={
role() !== 'COMPANY' && role() !== 'JOB_SEEKER' && role() !== 'CUSTOMER' && role() !== 'USER'
}>
<ProfessionalKPIs />
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<For each={rc()?.verification_status?.missing_requirements ?? ['Profile', 'Portfolio']}>
{(req) => (
<div style={{
padding: '12px 16px',
background: '#fff',
border: '1px solid #fecaca',
'border-radius': '12px',
display: 'flex',
'align-items': 'center',
gap: '10px'
}}>
<span style={{ color: '#ef4444' }}></span>
<span style={{ 'font-size': '14px', 'font-weight': '600', color: '#374151' }}>{req}</span>
<A href={req.toLowerCase().includes('portfolio') ? '/dashboard/portfolio' : '/dashboard/profile'}
style={{ 'margin-left': 'auto', 'font-size': '12px', 'font-weight': '700', color: '#fd6116', 'text-decoration': 'none' }}>
Fix
</A>
</div>
)}
</For>
</div>
</div>
</Show>
{/* Dynamic Widget Grid — rendered based on role_config from Admin */}
<div class="dashboard-widgets-grid" data-tour-id="dashboard-widgets">
<Show
when={rc()?.role_config?.widgets && rc()?.role_config.widgets.length > 0}
fallback={
<div class="kpi-grid">
<Show when={role() === 'USER'}>
<div class="kpi-card">
<div class="kpi-icon kpi-icon--blue">🧭</div>
<div class="kpi-content">
<div class="kpi-value">Get Started</div>
<div class="kpi-label">Choose a role to unlock role-based onboarding and modules</div>
</div>
<A href="/dashboard/explore" class="kpi-link">Explore Nxtgauge </A>
</div>
</Show>
<Show when={role() === 'COMPANY'}><CompanyKPIs /></Show>
<Show when={role() === 'JOB_SEEKER'}><JobSeekerKPIs /></Show>
<Show when={role() === 'CUSTOMER'}><CustomerKPIs /></Show>
<Show when={role() !== 'COMPANY' && role() !== 'JOB_SEEKER' && role() !== 'CUSTOMER' && role() !== 'USER'}>
<ProfessionalKPIs />
</Show>
</div>
}
>
<For each={rc()?.role_config.widgets}>
{(widgetKey: string) => {
const WidgetComponent = WIDGET_COMPONENTS[widgetKey];
if (!WidgetComponent) return null;
return <WidgetComponent role={role()} data={rc()?.dashboard_data?.[widgetKey]} />;
}}
</For>
</Show>
</div>
</div>

View file

@ -42,13 +42,22 @@ export default function JobApplications() {
if (res.ok) refetch();
}
const [contactModal, setContactModal] = createSignal<any>(null);
const [viewingContactId, setViewingContactId] = createSignal<string | null>(null);
async function viewContact(applicationId: string) {
setViewingContactId(applicationId);
const res = await fetch(`${API}/api/companies/applications/${applicationId}/contact`, {
headers: getAuthHeader(),
});
setViewingContactId(null);
if (res.ok) {
const data = await res.json();
alert(`📞 Contact Info\nName: ${data.full_name}\nEmail: ${data.email}\nPhone: ${data.phone}`);
setContactModal(data);
refetch(); // In case quota decreased, update UI if needed
} else {
const err = await res.json();
alert(`Error revealing contact: ${err.error || 'Quota exhausted?'}`);
}
}
@ -166,6 +175,36 @@ export default function JobApplications() {
</table>
</Show>
</div>
<Show when={contactModal()}>
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', 'align-items': 'center', 'justify-content': 'center', 'z-index': 100 }}>
<div style={{ background: '#fff', padding: '32px', 'border-radius': '12px', 'max-width': '400px', width: '100%', 'box-shadow': '0 20px 25px -5px rgba(0,0,0,0.1)' }}>
<h3 style={{ margin: '0 0 16px', 'font-size': '20px', 'font-weight': '800' }}>Contact Unlocked 🎉</h3>
<div style={{ padding: '16px', background: '#f8fafc', 'border-radius': '8px', 'margin-bottom': '24px' }}>
<div style={{ 'margin-bottom': '12px' }}>
<div style={{ 'font-size': '11px', color: '#64748b', 'text-transform': 'uppercase', 'font-weight': '700' }}>Name</div>
<div style={{ 'font-weight': '600' }}>{contactModal().full_name || 'N/A'}</div>
</div>
<div style={{ 'margin-bottom': '12px' }}>
<div style={{ 'font-size': '11px', color: '#64748b', 'text-transform': 'uppercase', 'font-weight': '700' }}>Email</div>
<div style={{ 'font-weight': '500' }}>
<a href={`mailto:${contactModal().email}`} style={{ color: '#2563eb', 'text-decoration': 'none' }}>{contactModal().email || 'N/A'}</a>
</div>
</div>
<div>
<div style={{ 'font-size': '11px', color: '#64748b', 'text-transform': 'uppercase', 'font-weight': '700' }}>Phone</div>
<div style={{ 'font-weight': '500' }}>
{contactModal().phone ? <a href={`tel:${contactModal().phone}`} style={{ color: '#2563eb', 'text-decoration': 'none' }}>{contactModal().phone}</a> : 'N/A'}
</div>
</div>
</div>
<button class="btn btn-primary" style={{ width: '100%', 'justify-content': 'center' }} onClick={() => setContactModal(null)}>
Close
</button>
</div>
</div>
</Show>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { createSignal, Show } from 'solid-js';
import { createSignal, Show, createResource } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { getAuthHeader } from '~/lib/auth';
import { getAuthHeader, authState } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
@ -10,7 +10,23 @@ export default function CreateJob() {
const navigate = useNavigate();
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal('');
const [usage] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization) return { count: 0 };
// Fetch recent jobs to count monthly usage
const res = await fetch(`${API}/api/companies/jobs?limit=100`, { headers: auth });
if (!res.ok) return { count: 0 };
const data = await res.json();
const count = data.data?.filter((j: any) => {
const date = new Date(j.created_at);
const now = new Date();
return date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear();
}).length || 0;
return { count };
});
const isFree = () => (usage()?.count ?? 0) < 1;
const [form, setForm] = createSignal({
title: '',
category: '',
@ -72,11 +88,22 @@ export default function CreateJob() {
return (
<div style={{ 'max-width': '720px' }}>
<div style={{ 'margin-bottom': '24px' }}>
<h1 style={{ margin: 0, 'font-size': '22px', 'font-weight': '800' }}>Post a New Job</h1>
<p style={{ margin: '6px 0 0', color: '#64748b', 'font-size': '14px' }}>
Job will be saved as Draft and require admin approval before going live.
</p>
<div class="table-card" style={{ padding: '16px 20px', 'margin-bottom': '24px', background: isFree() ? '#f0fdf4' : '#fff7ed', border: isFree() ? '1px solid #bbf7d0' : '1px solid #fed7aa' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center' }}>
<div>
<h3 style={{ margin: 0, 'font-size': '15px', 'font-weight': '700', color: isFree() ? '#166534' : '#9a3412' }}>
{isFree() ? '✨ Monthly Free Job Posting' : '🪙 Paid Job Posting'}
</h3>
<p style={{ margin: '4px 0 0', 'font-size': '13px', color: isFree() ? '#15803d' : '#c2410c' }}>
{isFree()
? "This is your 1st job this month—it's completely free!"
: "Your monthly free quota is exhausted. Posting this job will cost 100 Tracecoins."}
</p>
</div>
<div style={{ 'font-size': '20px', 'font-weight': '800', color: isFree() ? '#15803d' : '#c2410c' }}>
{isFree() ? 'FREE' : '100 🪙'}
</div>
</div>
</div>
<Show when={error()}>

View file

@ -40,9 +40,20 @@ export default function CompanyJobs() {
Job Postings
</h1>
</div>
<Show when={hasModule('JOBS_CREATE')}>
<A href="/dashboard/jobs/create" class="btn btn-primary">+ Post a Job</A>
</Show>
<div style={{ display: 'flex', 'align-items': 'center', gap: '16px' }}>
<Show when={jobs()?.data}>
<div class="note" style={{ margin: 0, padding: '8px 14px', background: '#f8fafc', 'border-radius': '8px', 'font-size': '13px', color: '#475569' }}>
Free Jobs: <strong>{jobs()?.data?.filter((j: any) => {
const date = new Date(j.created_at);
const now = new Date();
return date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear();
}).length >= 1 ? '1/1 Used' : '0/1 Used'}</strong>
</div>
</Show>
<Show when={hasModule('JOBS_CREATE')}>
<A href="/dashboard/jobs/create" class="btn btn-primary">+ Post a Job</A>
</Show>
</div>
</div>
{/* Filter bar */}

View file

@ -1,10 +1,93 @@
import { createResource, Show, For } from 'solid-js';
import { getAuthHeader } from '~/lib/auth';
import { A } from '@solidjs/router';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
export default function DashboardAcceptedLeadsPage() {
const [leads] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/leads/requests/me?status=ACCEPTED&limit=50`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
});
return (
<section class="dashboard-card">
<h1>Accepted Leads</h1>
<p class="dashboard-muted">
Accepted lead details and contact visibility are being finalized for the shared professional flow.
</p>
</section>
<div style={{ 'max-width': '900px' }}>
<div class="page-header" style={{ 'margin-bottom': '24px' }}>
<h1 style={{ 'font-size': '24px', 'font-weight': '800', margin: '0 0 8px' }}>Accepted Leads</h1>
<p style={{ color: '#64748b', margin: 0 }}>Customers who have accepted your request. Here are their contact details securely unlocked.</p>
</div>
<Show when={leads.loading}>
<div class="loading-spinner">Loading leads...</div>
</Show>
<Show when={!leads.loading && leads()?.data?.length === 0}>
<div class="table-card" style={{ padding: '48px 24px', 'text-align': 'center' }}>
<div style={{ 'font-size': '32px', 'margin-bottom': '16px' }}>🤝</div>
<h3 style={{ 'margin-top': 0, color: '#334155' }}>No accepted leads yet</h3>
<p style={{ color: '#64748b', 'max-width': '400px', margin: '0 auto 24px' }}>
When a customer accepts your request, their contact details will be permanently unlocked and securely listed here.
</p>
<A href="/dashboard/requests" class="btn btn-primary" style={{ 'text-decoration': 'none' }}>View My Requests</A>
</div>
</Show>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(auto-fill, minmax(380px, 1fr))', gap: '16px' }}>
<For each={leads()?.data}>
{(item: any) => {
const lead = item.id ? item : item.lead;
const meta = item.id ? item : item;
return (
<div class="table-card" style={{ padding: '20px', display: 'flex', 'flex-direction': 'column', 'justify-content': 'space-between' }}>
<div>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-start', 'margin-bottom': '12px' }}>
<span class="badge badge--green">Accepted </span>
<span style={{ 'font-size': '12px', color: '#94a3b8' }}>
{new Date(lead.resolved_at || lead.updated_at).toLocaleDateString('en-IN')}
</span>
</div>
<h3 style={{ 'font-size': '16px', 'font-weight': '700', margin: '0 0 4px', color: '#0f172a' }}>
{meta.req_title || 'Private Requirement'}
</h3>
<div style={{ 'font-size': '13px', color: '#64748b', 'margin-bottom': '16px' }}>
<Show when={meta.req_location}>
<span style={{ 'margin-right': '12px' }}>📍 {meta.req_location}</span>
</Show>
<Show when={meta.req_budget}>
<span>💰 {(meta.req_budget / 100).toLocaleString('en-IN')}</span>
</Show>
</div>
</div>
{/* Secure Contact Reveal Card */}
<div style={{ 'background': '#f8fafc', border: '1px solid #e2e8f0', 'border-radius': '6px', padding: '16px' }}>
<div style={{ 'font-size': '11px', 'font-weight': '700', color: '#94a3b8', 'text-transform': 'uppercase', 'margin-bottom': '12px' }}>Customer Contact</div>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px', 'margin-bottom': '8px', color: '#0f172a', 'font-weight': '600' }}>
👤 {meta.customer_name || 'N/A'}
</div>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px', 'margin-bottom': '8px', color: '#334155', 'font-size': '14px' }}>
<a href={`mailto:${meta.customer_email}`} style={{ color: '#2563eb', 'text-decoration': 'none' }}>{meta.customer_email || 'Hidden'}</a>
</div>
<Show when={meta.customer_phone}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px', color: '#334155', 'font-size': '14px' }}>
📞 <a href={`tel:${meta.customer_phone}`} style={{ color: '#2563eb', 'text-decoration': 'none' }}>{meta.customer_phone}</a>
</div>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { createResource, createSignal, Show } from 'solid-js';
import { useParams, useNavigate } from '@solidjs/router';
import { getAuthHeader } from '~/lib/auth';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
@ -12,10 +12,13 @@ export default function MarketplaceDetail() {
const [requested, setRequested] = createSignal(false);
const [error, setError] = createSignal('');
const [req] = createResource(reqId, async (id) => {
const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role);
const [req] = createResource(() => ({ id: reqId(), role: rc()?.role }), async ({ id, role }) => {
const auth = getAuthHeader();
if (!auth.Authorization) return null;
const res = await fetch(`${API}/api/photographers/marketplace/${id}`, { headers: auth });
if (!auth.Authorization || !role) return null;
const res = await fetch(`${API}${rolePrefix()}/marketplace/${id}`, { headers: auth });
if (!res.ok) return null;
return res.json();
});
@ -23,7 +26,7 @@ export default function MarketplaceDetail() {
async function sendRequest() {
setRequesting(true);
setError('');
const res = await fetch(`${API}/api/photographers/leads/request`, {
const res = await fetch(`${API}${rolePrefix()}/leads/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify({ requirement_id: reqId() }),
@ -75,14 +78,27 @@ export default function MarketplaceDetail() {
</Show>
<Show when={!requested()}>
<div style={{ display: 'flex', gap: '12px', 'align-items': 'center' }}>
<button class="btn btn-primary" disabled={requesting()} onClick={sendRequest}
style={{ 'font-size': '15px', padding: '12px 24px' }}>
{requesting() ? 'Sending...' : '🪙 Send Request (25 Tracecoins)'}
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}>
<button
class="btn btn-primary"
disabled={requesting() || req()?.request_count >= 20}
onClick={sendRequest}
style={{ 'font-size': '15px', padding: '12px 24px', width: '100%' }}
>
<Show when={req()?.request_count < 20} fallback="Lead Full (20/20)">
{requesting() ? 'Sending...' : '🪙 Send Request (25 Tracecoins)'}
</Show>
</button>
<span style={{ 'font-size': '12px', color: '#64748b' }}>
25 coins reserved returned if rejected or expired
</span>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'center' }}>
<span style={{ 'font-size': '12px', color: '#64748b' }}>
25 coins reserved returned if rejected or expired
</span>
<Show when={req()?.request_count >= 15}>
<span style={{ 'font-size': '12px', 'font-weight': '700', color: '#fd6116' }}>
🔥 Almost Full!
</span>
</Show>
</div>
</div>
</Show>

View file

@ -1,6 +1,6 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import { getAuthHeader } from '~/lib/auth';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
@ -8,13 +8,16 @@ export default function Marketplace() {
const [search, setSearch] = createSignal('');
const [page, setPage] = createSignal(1);
const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role);
const [requirements, { refetch }] = createResource(
() => ({ page: page(), search: search() }),
async ({ page }) => {
() => ({ page: page(), search: search(), role: rc()?.role }),
async ({ page, role }) => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
if (!auth.Authorization || !role) return { data: [] };
const res = await fetch(
`${API}/api/professionals/marketplace?page=${page}&limit=20`,
`${API}${rolePrefix()}/marketplace?page=${page}&limit=20`,
{ headers: auth }
);
if (!res.ok) return { data: [] };
@ -52,6 +55,12 @@ export default function Marketplace() {
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px', 'margin-bottom': '6px' }}>
<span class="badge badge--blue">{req.profession_key}</span>
<Show when={req.request_count >= 20}>
<span class="badge badge--red">Lead Full</span>
</Show>
<Show when={req.request_count >= 15 && req.request_count < 20}>
<span class="badge badge--orange">🔥 High Demand</span>
</Show>
<span style={{ color: '#94a3b8', 'font-size': '12px' }}>
{req.request_count ?? 0} / 20 requests
</span>

View file

@ -1,5 +1,5 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import { getAuthHeader } from '~/lib/auth';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
@ -9,12 +9,14 @@ export default function Portfolio() {
const [editId, setEditId] = createSignal<string | null>(null);
const [error, setError] = createSignal('');
const [form, setForm] = createSignal({ title: '', description: '', tags: '' });
const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role);
function f(k: string) { return (e: any) => setForm(p => ({ ...p, [k]: e.target.value })); }
const [items, { refetch }] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/photographers/portfolio/me`, { headers: auth });
if (!auth.Authorization || !rc()?.role) return { data: [] };
const res = await fetch(`${API}${rolePrefix()}/portfolio/me`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
});
@ -32,8 +34,8 @@ export default function Portfolio() {
};
const isEdit = !!editId();
const url = isEdit
? `${API}/api/photographers/portfolio/${editId()}`
: `${API}/api/photographers/portfolio`;
? `${API}${rolePrefix()}/portfolio/${editId()}`
: `${API}${rolePrefix()}/portfolio`;
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
@ -53,7 +55,7 @@ export default function Portfolio() {
async function deleteItem(id: string) {
if (!confirm('Delete this portfolio item?')) return;
await fetch(`${API}/api/photographers/portfolio/${id}`, {
await fetch(`${API}${rolePrefix()}/portfolio/${id}`, {
method: 'DELETE',
headers: getAuthHeader(),
});

View file

@ -1,22 +1,94 @@
import { Show } from 'solid-js';
import { Show, For } from 'solid-js';
import { authState } from '~/lib/auth';
export default function DashboardProfilePage() {
const rc = () => authState().runtime_config;
return (
<section class="dashboard-card">
<h1>Profile</h1>
<p class="dashboard-muted">Your active account details for this role.</p>
<Show when={rc()} fallback={<p class="dashboard-muted">Loading profile...</p>}>
<div class="kv-grid">
<div><strong>Name:</strong> {rc()?.user?.full_name || '—'}</div>
<div><strong>Email:</strong> {rc()?.user?.email || '—'}</div>
<div><strong>Active Role:</strong> {rc()?.user?.active_role || rc()?.role || '—'}</div>
<div><strong>Roles:</strong> {(rc()?.user?.roles || []).join(', ') || '—'}</div>
<div style={{ 'max-width': '800px' }}>
<div class="page-header" style={{ 'margin-bottom': '24px', display: 'flex', 'justify-content': 'space-between', 'align-items': 'center' }}>
<div>
<h1 style={{ 'font-size': '24px', 'font-weight': '800', margin: '0 0 8px' }}>Profile Details</h1>
<p style={{ color: '#64748b', margin: 0 }}>View your account and role-specific profile information.</p>
</div>
<a
href="/choose-role"
class="btn btn--primary"
style={{ 'text-decoration': 'none', 'font-size': '13px', padding: '8px 16px' }}
>
Add Another Role
</a>
</div>
<Show when={rc()} fallback={<div class="loading-spinner">Loading profile...</div>}>
<div class="table-card" style={{ padding: '24px', 'margin-bottom': '24px' }}>
<h2 style={{ 'font-size': '16px', 'font-weight': '700', 'margin-bottom': '16px' }}>Basic Account</h2>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '16px' }}>
<div>
<div style={{ 'font-size': '11px', 'font-weight': '700', color: '#94a3b8', 'text-transform': 'uppercase' }}>Full Name</div>
<div style={{ 'font-weight': '600', 'margin-top': '4px' }}>{rc()?.user?.full_name || '—'}</div>
</div>
<div>
<div style={{ 'font-size': '11px', 'font-weight': '700', color: '#94a3b8', 'text-transform': 'uppercase' }}>Email Address</div>
<div style={{ 'font-weight': '600', 'margin-top': '4px' }}>{rc()?.user?.email || '—'}</div>
</div>
<div>
<div style={{ 'font-size': '11px', 'font-weight': '700', color: '#94a3b8', 'text-transform': 'uppercase' }}>Phone</div>
<div style={{ 'font-weight': '600', 'margin-top': '4px' }}>{rc()?.user?.phone || '—'}</div>
</div>
<div>
<div style={{ 'font-size': '11px', 'font-weight': '700', color: '#94a3b8', 'text-transform': 'uppercase' }}>Active Gateway Role</div>
<div style={{ 'font-weight': '600', 'margin-top': '4px' }}>
<span class="badge badge--blue">{rc()?.role || rc()?.user?.active_role || '—'}</span>
</div>
</div>
</div>
</div>
{/* Dynamic Role-Specific Metadata via Role Builder UI Config */}
<Show when={rc()?.role_config?.profile_schema?.sections}>
<For each={rc()?.role_config?.profile_schema?.sections}>
{(section: any) => (
<div class="table-card" style={{ padding: '24px', 'margin-bottom': '24px' }}>
<h2 style={{ 'font-size': '16px', 'font-weight': '700', 'margin-bottom': '16px' }}>
{section.title}
</h2>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr', gap: '16px' }}>
<For each={section.fields}>
{(fieldDef: any) => {
// Profile data rests on `rc()?.profile`. Extract appropriate field!
let val = rc()?.profile?.[fieldDef.name];
if (val === undefined || val === null) {
// Check extra_data_json if not root field
val = rc()?.profile?.extra_data_json?.[fieldDef.name];
}
// Handle array display neatly
const displayVal = Array.isArray(val) ? val.join(', ') : String(val ?? '—');
return (
<div>
<div style={{ 'font-size': '11px', 'font-weight': '700', color: '#94a3b8', 'text-transform': 'uppercase' }}>
{fieldDef.label}
</div>
<div style={{ 'font-weight': '600', 'margin-top': '4px', 'word-break': 'break-word' }}>
{fieldDef.options && val ? (
fieldDef.options.find((o: any) => o.value === val)?.label || displayVal
) : (
displayVal
)}
</div>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</Show>
</Show>
</section>
</div>
);
}

View file

@ -1,10 +1,134 @@
import { createResource, Show, For } from 'solid-js';
import { getAuthHeader } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
const REQ_STATUS_BADGE: Record<string, string> = {
PENDING: 'badge--gray',
ACCEPTED: 'badge--green',
REJECTED: 'badge--red',
EXPIRED: 'badge--orange',
CANCELLED: 'badge--gray',
};
// Based on the updated backend JSON: { lead: { id, status, expires_at... }, req_title, req_profession_key... }
export default function DashboardRequestsPage() {
const [requests, { refetch }] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
// Depending on the profession, the gateway will route /api/professionals/requests correctly!
// Wait, the new API route is /api/leads/requests/me in the proxy, but wait:
// The Rust backend defines it as /api/leads/requests/me on the Profession handler!
const res = await fetch(`${API}/api/leads/requests/me?limit=50`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
});
async function cancelRequest(id: string) {
if (!confirm('Are you sure you want to cancel this request? 25 Tracecoins will be unreserved.')) return;
const auth = getAuthHeader();
const res = await fetch(`${API}/api/leads/requests/${id}/cancel`, {
method: 'POST',
headers: auth,
});
if (res.ok) {
refetch();
} else {
alert('Failed to cancel request');
}
}
return (
<section class="dashboard-card">
<h1>My Lead Requests</h1>
<p class="dashboard-muted">
Role-specific lead request listing is enabled on the backend and is being connected to this page.
</p>
</section>
<div style={{ 'max-width': '900px' }}>
<div class="page-header" style={{ 'margin-bottom': '24px' }}>
<h1 style={{ 'font-size': '24px', 'font-weight': '800', margin: '0 0 8px' }}>My Lead Requests</h1>
<p style={{ color: '#64748b', margin: 0 }}>Track the status of leads you have requested.</p>
</div>
<Show when={requests.loading}>
<div class="loading-spinner">Loading leads...</div>
</Show>
<Show when={!requests.loading && requests()?.data?.length === 0}>
<div class="table-card" style={{ padding: '48px 24px', 'text-align': 'center' }}>
<div style={{ 'font-size': '32px', 'margin-bottom': '16px' }}>👀</div>
<h3 style={{ 'margin-top': 0, color: '#334155' }}>No requests sent yet</h3>
<p style={{ color: '#64748b', 'max-width': '400px', margin: '0 auto 24px' }}>
When you request a lead from the marketplace, it will appear here. The 25 Tracecoins are held in reserve until the customer decides.
</p>
<a href="/dashboard/marketplace" class="btn btn-primary">Browse Marketplace</a>
</div>
</Show>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '16px' }}>
<For each={requests()?.data}>
{(item: any) => {
const req = item.id ? item : item.lead; // Support flattened or nested format
const meta = item.id ? item : item; // The metadata is always alongside
return (
<div class="table-card" style={{ padding: '24px' }}>
<div style={{ display: 'flex', 'justify-content': 'space-between', 'align-items': 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', 'align-items': 'center', gap: '12px', 'margin-bottom': '8px' }}>
<span class={`badge ${REQ_STATUS_BADGE[req.status] ?? 'badge--gray'}`}>{req.status}</span>
<Show when={meta.req_profession_key}>
<span class="badge badge--blue">{meta.req_profession_key.replace(/_/g, ' ')}</span>
</Show>
</div>
<h3 style={{ 'font-size': '18px', 'font-weight': '700', margin: '0 0 6px', color: '#0f172a' }}>
{meta.req_title || 'Unknown Requirement Requirement'}
</h3>
<div style={{ display: 'flex', gap: '16px', color: '#64748b', 'font-size': '13px', 'margin-bottom': '12px' }}>
<Show when={meta.req_location}>
<span>📍 {meta.req_location}</span>
</Show>
<Show when={meta.req_budget}>
<span>💰 {(meta.req_budget / 100).toLocaleString('en-IN')}</span>
</Show>
</div>
<div style={{ 'font-size': '12px', color: '#94a3b8', display: 'flex', gap: '12px' }}>
<span>Requested: {new Date(req.requested_at).toLocaleDateString('en-IN')}</span>
<span>Expires: {new Date(req.expires_at).toLocaleDateString('en-IN')}</span>
</div>
</div>
<div style={{ 'text-align': 'right' }}>
<Show when={req.status === 'PENDING'}>
<div style={{ 'margin-bottom': '8px', 'font-size': '13px', 'font-weight': '600', color: '#ca8a04' }}>
25 Tracecoins reserved
</div>
<button class="btn btn-sm" style={{ 'border-color': '#cbd5e1', color: '#64748b' }} onClick={() => cancelRequest(req.id)}>
Cancel Request
</button>
</Show>
<Show when={req.status === 'EXPIRED'}>
<div style={{ 'font-size': '13px', color: '#64748b' }}>
Customer did not respond in 24h.<br/>Tracecoins safely returned.
</div>
</Show>
<Show when={req.status === 'REJECTED'}>
<div style={{ 'font-size': '13px', color: '#e11d48' }}>
Request declined.<br/>Tracecoins safely returned.
</div>
</Show>
<Show when={req.status === 'ACCEPTED'}>
<div style={{ 'font-size': '13px', color: '#16a34a', 'font-weight': '700' }}>
Accepted! View in Active Leads
</div>
</Show>
</div>
</div>
</div>
);
}}
</For>
</div>
</div>
);
}

View file

@ -76,7 +76,7 @@ export default function RequirementDetail() {
{/* Requirement summary */}
<div class="table-card" style={{ padding: '20px', 'margin-bottom': '20px' }}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr 1fr', gap: '16px' }}>
<div style={{ display: 'grid', 'grid-template-columns': '1fr 1fr 1fr 1fr', gap: '16px' }}>
<div>
<div style={{ 'font-size': '11px', 'font-weight': '700', color: '#94a3b8', 'text-transform': 'uppercase' }}>Location</div>
<div style={{ 'font-weight': '600', 'margin-top': '4px' }}>{req()?.location}</div>
@ -91,6 +91,12 @@ export default function RequirementDetail() {
<div style={{ 'font-size': '11px', 'font-weight': '700', color: '#94a3b8', 'text-transform': 'uppercase' }}>Requests</div>
<div style={{ 'font-weight': '600', 'margin-top': '4px' }}>{req()?.request_count ?? 0} / 20</div>
</div>
<div>
<div style={{ 'font-size': '11px', 'font-weight': '700', color: (requests()?.data?.filter((r: any) => r.status === 'ACCEPTED').length >= 10) ? '#e11d48' : '#94a3b8', 'text-transform': 'uppercase' }}>Unlocked</div>
<div style={{ 'font-weight': '800', 'margin-top': '4px', color: (requests()?.data?.filter((r: any) => r.status === 'ACCEPTED').length >= 10) ? '#e11d48' : 'inherit' }}>
{requests()?.data?.filter((r: any) => r.status === 'ACCEPTED').length ?? 0} / 10
</div>
</div>
</div>
<Show when={req()?.description}>
<div style={{ 'margin-top': '16px' }}>
@ -141,10 +147,12 @@ export default function RequirementDetail() {
<div style={{ display: 'flex', gap: '8px', 'flex-shrink': 0 }}>
<button
class="btn btn-primary btn-sm"
disabled={actionLoading() !== ''}
disabled={actionLoading() !== '' || (requests()?.data?.filter((r: any) => r.status === 'ACCEPTED').length >= 10)}
onClick={() => approve(req.id)}
title={requests()?.data?.filter((r: any) => r.status === 'ACCEPTED').length >= 10 ? "Limit of 10 accepted professionals reached." : ""}
>
{actionLoading() === req.id + '_approve' ? '...' : '✓ Accept'}
{actionLoading() === req.id + '_approve' ? '...' :
(requests()?.data?.filter((r: any) => r.status === 'ACCEPTED').length >= 10) ? 'Limit Reached' : '✓ Accept'}
</button>
<button
class="btn btn-sm"

View file

@ -1,6 +1,6 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import { getAuthHeader } from '~/lib/auth';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
@ -9,8 +9,12 @@ export default function Requirements() {
const [showCreate, setShowCreate] = createSignal(false);
const [creating, setCreating] = createSignal(false);
const [createError, setCreateError] = createSignal('');
const [activeCount, setActiveCount] = createSignal(0);
const PROFESSIONS = [
const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role);
const PROFESSIONS = () => rc()?.role_config?.professions ?? [
'PHOTOGRAPHER','MAKEUP_ARTIST','TUTOR','DEVELOPER',
'VIDEO_EDITOR','GRAPHIC_DESIGNER','SOCIAL_MEDIA_MANAGER',
'FITNESS_TRAINER','CATERING_SERVICES'
@ -27,12 +31,18 @@ export default function Requirements() {
const [requirements, { refetch }] = createResource(
() => page(),
async (p) => {
async (p: number) => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/customers/requirements?page=${p}&limit=20`, { headers: auth });
if (!auth.Authorization || !rc()?.role) return { data: [] };
const res = await fetch(`${API}${rolePrefix()}/requirements?page=${p}&limit=20`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
const data = await res.json();
// Calculate active count for limit enforcement
const count = data.data?.filter((r: any) => r.status === 'OPEN').length || 0;
setActiveCount(count);
return data;
}
);
@ -53,8 +63,17 @@ export default function Requirements() {
};
if (f.budget) body.budget = parseInt(f.budget) * 100; // paise
if (f.preferred_date) body.preferred_date = f.preferred_date;
// Support dynamic custom fields from role_config if needed
if (rc()?.role_config?.fields) {
rc()?.role_config.fields.forEach((field: any) => {
if (f[field.name as keyof typeof f]) {
body[field.name] = f[field.name as keyof typeof f];
}
});
}
const res = await fetch(`${API}/api/customers/requirements`, {
const res = await fetch(`${API}${rolePrefix()}/requirements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeader() },
body: JSON.stringify(body),
@ -83,11 +102,22 @@ export default function Requirements() {
You can have up to 2 active requirements.
</p>
</div>
<button class="btn btn-primary" onClick={() => setShowCreate(s => !s)}>
{showCreate() ? '✕ Cancel' : '+ Post Requirement'}
<button
class="btn btn-primary"
disabled={!showCreate() && activeCount() >= 2}
onClick={() => setShowCreate(s => !s)}
title={activeCount() >= 2 ? "You can only have 2 active requirements at a time." : ""}
>
{showCreate() ? '✕ Cancel' : activeCount() >= 2 ? 'Limit Reached (2)' : '+ Post Requirement'}
</button>
</div>
<Show when={activeCount() >= 2 && !showCreate()}>
<div class="note" style={{ color: '#991b1b', background: '#fef2f2', border: '1px solid #fee2e2', 'margin-bottom': '16px' }}>
You have reached your limit of 2 active requirements. Please close an existing one to post more.
</div>
</Show>
{/* Create Form */}
<Show when={showCreate()}>
<div class="table-card" style={{ padding: '24px', 'margin-bottom': '24px' }}>
@ -101,7 +131,7 @@ export default function Requirements() {
<label class="label">Profession Type *</label>
<select class="select" value={form().profession_key}
onChange={(e) => setForm(f => ({ ...f, profession_key: e.currentTarget.value }))}>
{PROFESSIONS.map(p => <option value={p}>{p.replace(/_/g, ' ')}</option>)}
{PROFESSIONS().map((p: string) => <option value={p}>{p.replace(/_/g, ' ')}</option>)}
</select>
</div>
<div class="field">

View file

@ -0,0 +1,84 @@
import { For } from 'solid-js';
import { authState } from '~/lib/auth';
export default function BuyTracecoins() {
const rc = () => authState().runtime_config;
const BUNDLES = [
{ id: 'basic', name: 'Basic Pack', coins: 100, price: 500, popular: false, description: 'Perfect for beginners' },
{ id: 'pro', name: 'Professional Pack', coins: 300, price: 1200, popular: true, description: 'Best value for active pros' },
{ id: 'elite', name: 'Elite Pack', coins: 1000, price: 3500, popular: false, description: 'Power user savings' },
];
return (
<div style={{ 'max-width': '1000px' }}>
<div style={{ 'margin-bottom': '32px' }}>
<h1 style={{ margin: 0, 'font-size': '24px', 'font-weight': '800' }}>Buy Tracecoins</h1>
<p style={{ margin: '6px 0 0', color: '#64748b', 'font-size': '15px' }}>
Top up your wallet to send more lead requests and unlock contacts.
</p>
</div>
<div style={{ display: 'grid', 'grid-template-columns': 'repeat(auto-fit, minmax(300px, 1fr))', gap: '20px' }}>
<For each={BUNDLES}>
{(bundle) => (
<div
class="table-card"
style={{
padding: '32px',
position: 'relative',
display: 'flex',
'flex-direction': 'column',
'align-items': 'center',
border: bundle.popular ? '2px solid #fd6116' : '1px solid rgba(16, 11, 47, 0.08)'
}}
>
{bundle.popular && (
<div style={{
position: 'absolute',
top: '-12px',
background: '#fd6116',
color: '#fff',
padding: '2px 12px',
'border-radius': '999px',
'font-size': '11px',
'font-weight': '800',
'text-transform': 'uppercase'
}}>
Most Popular
</div>
)}
<h3 style={{ margin: '0 0 8px', 'font-size': '18px', 'font-weight': '800' }}>{bundle.name}</h3>
<p style={{ margin: '0 0 24px', color: '#64748b', 'font-size': '13px', 'text-align': 'center' }}>{bundle.description}</p>
<div style={{ 'font-size': '48px', 'font-weight': '800', color: '#111827', 'margin-bottom': '4px' }}>
🪙 {bundle.coins}
</div>
<div style={{ 'font-size': '14px', color: '#64748b', 'margin-bottom': '24px' }}>Tracecoins</div>
<div style={{ 'font-size': '24px', 'font-weight': '700', 'margin-bottom': '24px' }}>
{(bundle.price).toLocaleString('en-IN')}
</div>
<button
class="btn btn-primary"
style={{ width: '100%', 'padding-top': '14px', 'padding-bottom': '14px' }}
onClick={() => alert('Payment gateway integration coming soon.')}
>
Purchase Now
</button>
</div>
)}
</For>
</div>
<div class="table-card" style={{ 'margin-top': '40px', padding: '24px', background: '#f8fafc' }}>
<h4 style={{ margin: '0 0 12px', 'font-size': '15px', 'font-weight': '700' }}>Safe & Secure Payments</h4>
<p style={{ margin: 0, color: '#64748b', 'font-size': '13px', 'line-height': '1.6' }}>
All transactions are processed through encrypted payment gateways. Tracecoins are credited instantly to your wallet upon successful payment. Taxes may apply at checkout.
</p>
</div>
</div>
);
}

View file

@ -1,14 +1,17 @@
import { createResource, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import { getAuthHeader } from '~/lib/auth';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
export default function Wallet() {
const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role);
const [balance] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization) return { balance: 0, reserved: 0, available: 0 };
const res = await fetch(`${API}/api/professionals/wallet/balance`, { headers: auth });
if (!auth.Authorization || !rc()?.role) return { balance: 0, reserved: 0, available: 0 };
const res = await fetch(`${API}${rolePrefix()}/wallet/balance`, { headers: auth });
if (!res.ok) return { balance: 0, reserved: 0, available: 0 };
return res.json();
});

View file

@ -1,34 +1,43 @@
import { createResource, createSignal, Show, For } from 'solid-js';
import { A } from '@solidjs/router';
import { getAuthHeader } from '~/lib/auth';
import { getAuthHeader, authState, getRoleApiPath } from '~/lib/auth';
const API = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
export default function WalletLedger() {
const [page, setPage] = createSignal(1);
const rc = () => authState().runtime_config;
const rolePrefix = () => getRoleApiPath(rc()?.role);
const [balance] = createResource(async () => {
const auth = getAuthHeader();
if (!auth.Authorization) return { balance: 0, reserved: 0, available: 0 };
const res = await fetch(`${API}/api/photographers/wallet/balance`, { headers: auth });
if (!auth.Authorization || !rc()?.role) return { balance: 0, reserved: 0, available: 0 };
const res = await fetch(`${API}${rolePrefix()}/wallet/balance`, { headers: auth });
if (!res.ok) return { balance: 0, reserved: 0, available: 0 };
return res.json();
});
const [ledger] = createResource(() => page(), async (p) => {
const auth = getAuthHeader();
if (!auth.Authorization) return { data: [] };
const res = await fetch(`${API}/api/photographers/wallet/ledger?page=${p}&limit=30`, { headers: auth });
if (!auth.Authorization || !rc()?.role) return { data: [] };
const res = await fetch(`${API}${rolePrefix()}/wallet/ledger?page=${p}&limit=30`, { headers: auth });
if (!res.ok) return { data: [] };
return res.json();
});
const typeLabel: Record<string, string> = {
PURCHASE: '🛒 Purchased',
DEDUCTED: '🪙 Deducted (lead accepted)',
RESERVED: '🔒 Reserved (request sent)',
RETURNED: '↩️ Returned (rejected/expired)',
BONUS: '🎁 Bonus credited',
PURCHASE: '🪙 Coins Purchased',
DEDUCTED: '💸 Payment (Lead Accepted)',
RESERVED: '🔒 Reservation (Request Sent)',
RETURNED: '↩️ Refund (Rejected/Expired)',
BONUS: '🎁 Referral Bonus',
};
const getStatusColor = (tx: any) => {
if (tx.type === 'RESERVED') return '#64748b'; // Gray for pending
if (tx.type === 'RETURNED' || tx.amount > 0) return '#15803d'; // Green for incoming
return '#100b2f';
};
return (
@ -78,20 +87,35 @@ export default function WalletLedger() {
<Show when={ledger()?.data?.length > 0}>
<table class="data-table">
<thead>
<tr><th>Type</th><th>Amount</th><th>Description</th><th>Date</th></tr>
<tr><th>Type</th><th>Amount</th><th>Status</th><th>Reference</th><th>Date</th></tr>
</thead>
<tbody>
<For each={ledger()?.data}>
{(tx: any) => (
<tr>
<td>{typeLabel[tx.type] ?? tx.type}</td>
<tr>
<td style={{ 'font-weight': '600' }}>{typeLabel[tx.type] ?? tx.type}</td>
<td style={{
'font-weight': '700',
'font-weight': '800',
color: tx.amount < 0 ? '#e11d48' : '#15803d'
}}>
{tx.amount > 0 ? '+' : ''}{tx.amount} 🪙
</td>
<td style={{ color: '#64748b', 'font-size': '13px' }}>{tx.description ?? '—'}</td>
<td>
<span class="badge" style={{
background: tx.type === 'RESERVED' ? '#f1f5f9' : (tx.type === 'RETURNED' ? '#f0fdf4' : '#f8fafc'),
color: tx.type === 'RESERVED' ? '#64748b' : (tx.type === 'RETURNED' ? '#15803d' : '#475569'),
'font-size': '11px'
}}>
{tx.type === 'RESERVED' ? 'Awaiting Response' : (tx.type === 'RETURNED' ? 'Refunded' : 'Completed')}
</span>
</td>
<td>
<Show when={tx.metadata?.requirement_id} fallback={<span style={{ color: '#94a3b8' }}></span>}>
<A href={`/dashboard/marketplace/${tx.metadata.requirement_id}`} style={{ 'font-size': '12px', color: '#fd6116' }}>
View Lead
</A>
</Show>
</td>
<td style={{ 'font-size': '12px', color: '#94a3b8' }}>
{tx.created_at ? new Date(tx.created_at).toLocaleDateString('en-IN') : '—'}
</td>

View file

@ -1,6 +1,6 @@
import { createMemo, createSignal, For, onMount, Show } from 'solid-js';
import { useNavigate, useSearchParams } from '@solidjs/router';
import { authState, bootstrapAuth, setMockRuntimeConfig } from '~/lib/auth';
import { authState, bootstrapAuth, fetchRuntimeConfig, setMockRuntimeConfig } from '~/lib/auth';
function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
const token = authState().access_token;
@ -546,8 +546,16 @@ export default function OnboardingPage() {
}
setSubmitted(true);
setStatusMessage('');
// Navigate to dashboard after showing the success card for 3 seconds
setTimeout(() => navigate('/dashboard', { replace: true }), 3000);
// Refresh the runtime config so the new role is reflected in the switcher/dashboard
await fetchRuntimeConfig();
// Navigate to dashboard after showing the success card for 2 seconds
setTimeout(() => {
// If we registered a specific role, ensure we land on the dashboard with that role active
// In a real app, the backend should have updated the user's active_role during the /complete call
navigate('/dashboard', { replace: true });
}, 2000);
};
const RenderField = (props: { field: RuntimeOnboardingField }) => {

View file

@ -41,7 +41,7 @@ export default function PendingVerification() {
<div class="pending-request-box">
{status()?.document_request}
</div>
<A href="/onboarding" class="btn btn-primary">Upload Documents</A>
<A href="/dashboard" class="btn btn-primary">Visit Dashboard to Fix</A>
</Show>
<Show when={status()?.status === 'REJECTED'}>
@ -52,7 +52,7 @@ export default function PendingVerification() {
<strong>Reason:</strong> {status()?.rejection_reason}
</div>
</Show>
<A href="/onboarding" class="btn btn-primary">Resubmit</A>
<A href="/dashboard" class="btn btn-primary">Return to Dashboard</A>
</Show>
<div class="pending-support">