Added core pages, router, userStore and api paths

This commit is contained in:
Viswamedha Nalabotu 2026-01-18 11:32:47 +00:00
parent 271584864e
commit 939ce6479b
8 changed files with 1302 additions and 11 deletions

View file

@ -1,16 +1,352 @@
<script setup lang="ts">
import { computed, onMounted, type Component } from 'vue'
import { Layout, Menu, Button, Space, Typography, Select } from 'ant-design-vue'
import {
HomeOutlined,
InfoCircleOutlined,
RocketOutlined,
ReadOutlined,
TeamOutlined,
RobotOutlined,
BulbOutlined,
AppstoreOutlined,
DashboardOutlined,
LoginOutlined,
UserAddOutlined,
BuildOutlined,
} from '@ant-design/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from './stores/userStore'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
type NavItem = {
key: string
label: string
icon: Component
path?: string
manager?: boolean
children?: NavItem[]
}
const navItems: NavItem[] = [
{ key: '/', label: 'Home', icon: HomeOutlined, path: '/' },
{ key: '/about', label: 'About', icon: InfoCircleOutlined, path: '/about' },
{ key: '/onboarding', label: 'Onboarding', icon: RocketOutlined, path: '/onboarding' },
{ key: '/roles', label: 'Roles', icon: TeamOutlined, path: '/roles', manager: true },
{ key: '/agents', label: 'Agents', icon: RobotOutlined, path: '/agents', manager: true },
{ key: '/progress', label: 'Progress', icon: DashboardOutlined, path: '/progress' },
{
key: '/organizations',
label: 'Organizations',
icon: BuildOutlined,
path: '/organizations',
children: [
{ key: '/training', label: 'Training', icon: ReadOutlined, path: '/training' },
{ key: '/assessments', label: 'Assessments', icon: BulbOutlined, path: '/assessments' },
{ key: '/resources', label: 'Resources', icon: AppstoreOutlined, path: '/resources' },
],
},
]
const visibleNavItems = computed<NavItem[]>(() =>
navItems.filter((item) => (item.manager ? userStore.user?.is_manager : true)),
)
const selectedKeys = computed(() => {
for (const item of visibleNavItems.value) {
if (item.key === '/' && route.path === '/') return [item.key]
if (route.path.startsWith(item.key)) return [item.key]
if (item.children) {
const childMatch = item.children.find((c) => route.path.startsWith(c.key))
if (childMatch) return [item.key]
}
}
return []
})
type SimpleMenuInfo = { key: string | number | Array<string | number> }
const onSelect = (info: SimpleMenuInfo) => {
const key = String(info.key)
let found: NavItem | undefined
for (const item of visibleNavItems.value) {
if (item.key === key) {
found = item
break
}
if (item.children) {
const child = item.children.find((c) => c.key === key)
if (child) {
found = child
break
}
}
}
if (found && found.path && route.path !== found.path) {
const selectedOrgUuid = (
userStore as unknown as { selectedOrganizationUuid?: string | null }
).selectedOrganizationUuid
if (found.path === '/organizations' && selectedOrgUuid) {
router.push(`/organizations/${selectedOrgUuid}`)
} else {
router.push(found.path)
}
}
}
const handleLogout = async () => {
await userStore.logout()
router.push('/')
}
onMounted(() => {
userStore.fetchSession()
})
const user = userStore as unknown as {
organizations?: Array<{ uuid: string; name: string }>
selectedOrganizationUuid?: string | null
setSelectedOrganization?: (val: string | null) => void
displayName?: string
loading?: boolean
isAuthenticated?: boolean
}
</script>
<template> <template>
<div id="app"></div> <Layout class="shell">
<Layout.Header class="shell-header">
<div class="brand" @click="route.path !== '/' && router.push('/')">Dynavera</div>
<div style="margin-right: 1rem" v-if="user.isAuthenticated"></div>
<Menu
mode="horizontal"
theme="dark"
:selectedKeys="selectedKeys"
class="shell-menu"
@select="onSelect"
>
<template v-for="item in visibleNavItems" :key="item.key">
<Menu.SubMenu v-if="item.children" :key="`${item.key}-submenu`">
<template #title>
<span
@click.stop="
item.path && route.path !== item.path && router.push(item.path)
"
>
<Space size="small">
<component :is="item.icon" />
<span>{{ item.label }}</span>
</Space>
</span>
</template>
<Menu.Item
v-for="child in item.children"
:key="child.key"
@click="
child.path && route.path !== child.path && router.push(child.path)
"
>
<Space size="small">
<component :is="child.icon" />
<span>{{ child.label }}</span>
</Space>
</Menu.Item>
</Menu.SubMenu>
<Menu.Item
v-else
:key="`${item.key}-item`"
@click="item.path && route.path !== item.path && router.push(item.path)"
>
<Space size="small">
<component :is="item.icon" />
<span>{{ item.label }}</span>
</Space>
</Menu.Item>
</template>
</Menu>
<Space>
<template v-if="user.isAuthenticated">
<Select
v-if="user.organizations && user.organizations.length > 0"
:value="user.selectedOrganizationUuid ?? undefined"
@change="
(val) => {
user.setSelectedOrganization &&
user.setSelectedOrganization(val == null ? null : String(val))
}
"
style="min-width: 220px; margin-right: 0.5rem"
placeholder="Select organization"
>
<Select.Option
v-for="o in user.organizations"
:key="o.uuid"
:value="o.uuid"
>
{{ o.name }}
</Select.Option>
</Select>
<Typography.Text class="user-chip" strong>
{{ user.displayName || 'Account' }}
</Typography.Text>
<Button ghost :loading="user.loading" @click="handleLogout">Logout</Button>
</template>
<template v-else>
<Button ghost @click="router.push('/login')">
<LoginOutlined />
Login
</Button>
<Button type="primary" @click="router.push('/register')">
<UserAddOutlined />
Register
</Button>
</template>
</Space>
</Layout.Header>
<Layout class="shell-body">
<Layout.Content class="shell-content">
<router-view />
</Layout.Content>
<Layout.Footer class="shell-footer">
<Typography.Text type="secondary">
<strong>Project Disclaimer:</strong>
This is a proof-of-concept demo project for educational purposes. All
testimonials, statistics, and company names are fictional placeholders.
</Typography.Text>
</Layout.Footer>
</Layout>
</Layout>
</template> </template>
<script setup></script> <style scoped>
.shell {
<style> min-height: 100vh;
#app { background: #0b1220;
font-family: Avenir, Helvetica, Arial, sans-serif; }
-webkit-font-smoothing: antialiased; .shell-header {
-moz-osx-font-smoothing: grayscale; display: flex;
align-items: center;
gap: 1rem;
padding: 0 1.25rem;
background: #0f172a;
}
.brand {
color: #e5e7eb;
font-weight: 700;
cursor: pointer;
font-size: 1.05rem;
}
.shell-menu {
flex: 1;
background: transparent;
border-bottom: none;
}
.shell-body {
background: #0b1220;
min-height: calc(100vh - 64px);
display: flex;
flex-direction: column;
}
.shell-content {
padding: 24px;
flex: 1;
min-height: calc(100vh - 64px - 64px);
}
.shell-footer {
text-align: center; text-align: center;
color: #2c3e50; background: #0f172a;
margin-top: 60px; }
:deep(.ant-menu-dark) {
background: transparent;
}
:deep(.ant-menu-dark .ant-menu-item-selected) {
background: transparent !important;
}
:deep(.ant-typography),
:deep(.ant-typography p),
:deep(.ant-typography span),
:deep(.ant-list-item),
:deep(.ant-list-item-meta-title),
:deep(.ant-list-item-meta-description),
:deep(.ant-statistic-title),
:deep(.ant-statistic-content),
:deep(.ant-card-meta-title),
:deep(.ant-card-meta-description) {
color: #e5e7eb;
}
:deep(.ant-typography-secondary) {
color: #cbd5e1 !important;
}
:deep(.ant-form-item-label > label) {
color: #e5e7eb;
}
:deep(.ant-input),
:deep(.ant-select-selector),
:deep(.ant-select-selection-item),
:deep(.ant-picker-input input) {
background: #111827;
color: #e5e7eb;
border-color: #334155;
}
:deep(.ant-input::placeholder),
:deep(.ant-select-selection-placeholder),
:deep(.ant-picker-input input::placeholder) {
color: #9ca3af;
}
:deep(.ant-card) {
background: #0f172a;
border-color: #1f2937;
}
:deep(.ant-btn:not(.ant-btn-primary)) {
color: #e5e7eb;
border-color: #334155;
background: #111827;
}
:deep(.ant-btn-primary) {
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border: none;
}
.user-chip {
color: #e5e7eb;
}
:deep(.ant-typography-secondary) {
color: #cbd5e1 !important;
}
:deep(.ant-form-item-label > label) {
color: #e5e7eb;
}
:deep(.ant-input),
:deep(.ant-select-selector),
:deep(.ant-select-selection-item),
:deep(.ant-picker-input input) {
background: #111827;
color: #e5e7eb;
border-color: #334155;
}
:deep(.ant-input::placeholder),
:deep(.ant-select-selection-placeholder),
:deep(.ant-picker-input input::placeholder) {
color: #9ca3af;
}
:deep(.ant-card) {
background: #0f172a;
border-color: #1f2937;
}
:deep(.ant-btn:not(.ant-btn-primary)) {
color: #e5e7eb;
border-color: #334155;
background: #111827;
}
:deep(.ant-btn-primary) {
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border: none;
}
.user-chip {
color: #e5e7eb;
} }
</style> </style>

80
src/router/api.ts Normal file
View file

@ -0,0 +1,80 @@
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
class ApiClient {
private client: AxiosInstance
constructor() {
this.client = axios.create({ withCredentials: true })
}
private getCsrfToken(): string {
let cookieValue = ''
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';')
for (const rawCookie of cookies) {
const cookie = (rawCookie || '').trim()
if (cookie.startsWith('csrftoken=')) {
cookieValue = decodeURIComponent(cookie.slice('csrftoken='.length))
break
}
}
}
return cookieValue
}
private withCsrf(config?: AxiosRequestConfig): AxiosRequestConfig {
const token = this.getCsrfToken()
const csrfHeader = token ? { 'X-CSRFToken': token } : {}
return {
...config,
headers: {
...csrfHeader,
...(config?.headers || {}),
},
}
}
get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.get<T>(url, this.withCsrf(config))
}
post<T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.client.post<T>(url, data, this.withCsrf(config))
}
put<T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.client.put<T>(url, data, this.withCsrf(config))
}
patch<T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.client.patch<T>(url, data, this.withCsrf(config))
}
delete<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.delete<T>(url, this.withCsrf(config))
}
}
export const API = {
me: () => '/api/user/me/',
login: () => '/api/user/login/',
logout: () => '/api/user/logout/',
session: () => '/api/user/session/',
signup: () => '/api/user/signup/',
}
export const apiClient = new ApiClient()
export { isAxiosError } from 'axios'

View file

@ -1,8 +1,47 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/userStore'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [], routes: [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue'),
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
meta: { guestOnly: true },
},
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue'),
meta: { guestOnly: true },
},
],
})
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
const isAuthenticated = userStore.isAuthenticated
// const is_manager = userStore.user?.is_manager || false
if (to.meta?.guestOnly && isAuthenticated) {
return next({ path: '/' })
}
if (to.meta?.requiresAuth && !isAuthenticated) {
return next({ path: '/login', query: { redirect: to.fullPath } })
}
return next()
}) })
export default router export default router

159
src/stores/userStore.ts Normal file
View file

@ -0,0 +1,159 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiClient, isAxiosError, API } from '../router/api'
export interface User {
id: number
uuid: string
email_address: string
first_name: string
last_name: string
date_of_birth?: string
timezone?: string
avatar_url?: string
is_manager: boolean
created_at: string
updated_at: string
}
export interface SessionResponse {
isAuthenticated: boolean
isStaff: boolean
}
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const initialized = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => Boolean(user.value))
const displayName = computed(() => {
if (!user.value) return ''
if (user.value.first_name || user.value.last_name) {
return `${user.value.first_name || ''} ${user.value.last_name || ''}`.trim()
}
return user.value.email_address
})
const setUser = (value: User | null) => {
user.value = value
initialized.value = true
}
const fetchSession = async (force = false) => {
if (initialized.value && !force) return user.value
loading.value = true
error.value = null
try {
const sessionRes = await apiClient.get<SessionResponse>(API.session())
if (sessionRes.data?.isAuthenticated) {
const meRes = await apiClient.get<User>(API.me())
setUser(meRes.data)
} else {
setUser(null)
}
return user.value
} catch (err: unknown) {
if (isAxiosError(err)) {
error.value = err.response?.data?.detail || err.response?.data?.error || err.message
} else if (err instanceof Error) {
error.value = err.message
} else {
error.value = String(err)
}
setUser(null)
throw err
} finally {
loading.value = false
}
}
const login = async (emailAddress: string, password: string) => {
loading.value = true
error.value = null
try {
const res = await apiClient.post<{
user: User
message?: string
}>(API.login(), { email_address: emailAddress, password })
setUser(res.data?.user ?? null)
return res.data
} catch (err: unknown) {
if (isAxiosError(err)) {
error.value = err.response?.data?.error || err.response?.data?.detail || err.message
} else if (err instanceof Error) {
error.value = err.message
} else {
error.value = String(err)
}
throw err
} finally {
loading.value = false
}
}
const register = async (payload: {
email_address: string
password: string
confirm_password?: string
first_name: string
last_name: string
date_of_birth?: string
role?: string
}) => {
loading.value = true
error.value = null
try {
await apiClient.post(API.signup(), {
...payload,
confirm_password: payload.confirm_password || payload.password,
})
await login(payload.email_address, payload.password)
} catch (err: unknown) {
if (isAxiosError(err)) {
error.value = err.response?.data?.detail || err.response?.data?.error || err.message
} else if (err instanceof Error) {
error.value = err.message
} else {
error.value = String(err)
}
throw err
} finally {
loading.value = false
}
}
const logout = async () => {
loading.value = true
error.value = null
try {
await apiClient.post(API.logout())
} catch (err: unknown) {
if (isAxiosError(err)) {
error.value = err.response?.data?.detail || err.response?.data?.error || err.message
} else if (err instanceof Error) {
error.value = err.message
} else {
error.value = String(err)
}
throw err
} finally {
setUser(null)
loading.value = false
}
}
return {
user,
loading,
initialized,
error,
isAuthenticated,
displayName,
fetchSession,
login,
register,
logout,
}
})

113
src/views/AboutView.vue Normal file
View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import { Card, Typography, Divider, List, Timeline, Space } from 'ant-design-vue'
const pathways = [
'Admin: system settings, user management, reporting, invitations.',
'Manager: create onboarding flows, assign roles, monitor team progress.',
'Employee: complete training modules, assessments, and track personal progress.',
]
const highlights = [
'Ready for agent-driven workflows that guide people through onboarding tasks.',
'Flexible role-based gating across pages (managers/admins vs employees).',
'Django REST API + Vue 3 frontend with a shared Pinia auth/session store.',
'Docker-friendly dev setup (frontend on 5173, API on 8000).',
]
const roadmap = [
{
title: 'Short term',
items: [
'Add richer assessments with adaptive scoring.',
'Improve content versioning for training modules.',
'Expose activity feed for audits.',
],
},
{
title: 'Next',
items: [
'Integrate external IDP (SSO) and SCIM user sync.',
'Launch webhooks for downstream HRIS updates.',
'Add multilingual content support.',
],
},
]
const steps = [
'Register or login (demo credentials only).',
'Complete Onboarding and Training to simulate a role journey.',
'Managers assign employees to roles and review progress reports.',
]
</script>
<template>
<div class="page">
<Card class="panel" :bordered="false">
<Typography.Title :level="2">About Agentic Trainers</Typography.Title>
<Typography.Paragraph type="secondary">
Agentic Trainers is a lightweight platform for onboarding, training, and assessing
employees with modular content and agent-driven workflows. It is designed for teams
that want to ship tangible learning experiences quickly without complex LMS setup.
</Typography.Paragraph>
<Divider />
<Typography.Title :level="4">Role pathways</Typography.Title>
<List :data-source="pathways" :bordered="false">
<template #renderItem="{ item }">
<List.Item class="row">{{ item }}</List.Item>
</template>
</List>
<Divider />
<Typography.Title :level="4">Highlights</Typography.Title>
<List :data-source="highlights" :bordered="false">
<template #renderItem="{ item }">
<List.Item class="row">{{ item }}</List.Item>
</template>
</List>
<Divider />
<Typography.Title :level="4">Getting started</Typography.Title>
<List :data-source="steps" :bordered="false">
<template #renderItem="{ item, index }">
<List.Item class="row">
<strong>{{ index + 1 }}.</strong>
&nbsp;{{ item }}
</List.Item>
</template>
</List>
<Divider />
<Typography.Title :level="4">Roadmap</Typography.Title>
<Space :size="24" direction="vertical" style="width: 100%">
<Timeline>
<Timeline.Item v-for="bucket in roadmap" :key="bucket.title">
<Typography.Text strong>{{ bucket.title }}</Typography.Text>
<List :data-source="bucket.items" :bordered="false">
<template #renderItem="{ item }">
<List.Item class="row">{{ item }}</List.Item>
</template>
</List>
</Timeline.Item>
</Timeline>
</Space>
<Typography.Paragraph type="secondary" style="margin-top: 1rem">
Demo-only auth; integrate a real identity provider for production use.
</Typography.Paragraph>
</Card>
</div>
</template>
<style scoped>
.page {
padding: 2rem 1.5rem;
}
.panel {
max-width: 900px;
margin: 0 auto;
}
.row {
padding: 0.5rem 0;
}
</style>

275
src/views/HomeView.vue Normal file
View file

@ -0,0 +1,275 @@
<script setup lang="ts">
import {
Row,
Col,
Card,
Button,
Typography,
Tag,
Statistic,
Carousel,
Avatar,
Space,
Divider,
} from 'ant-design-vue'
import { CheckCircleTwoTone, ThunderboltTwoTone, CloudTwoTone } from '@ant-design/icons-vue'
const heroImage =
'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=1400&q=80'
const stats = [
{ title: 'Teams Onboarded', value: '240+' },
{ title: 'Avg. Time Saved', value: '38%' },
{ title: 'Playbooks Ready', value: '120' },
]
const features = [
{
title: 'Adaptive AI Guides',
description:
'Role-specific checklists, interactive tours, and contextual help tuned to your stack.',
icon: CheckCircleTwoTone,
},
{
title: 'Skills & Assessments',
description:
'Scenario-based quizzes and code tasks with instant insights and coach-like feedback.',
icon: ThunderboltTwoTone,
},
{
title: 'Knowledge Mesh',
description:
'Ingest docs, wikis, and repos — keep assistants current with zero manual updates.',
icon: CloudTwoTone,
},
]
const journeys = [
{
name: 'Engineer Launch',
steps: 'Access, environments, codebase tour, first PR, observability basics.',
image: 'https://images.unsplash.com/photo-1522075469751-3a6694fb2f61?auto=format&fit=crop&w=800&q=80',
},
{
name: 'Customer Success Ramp',
steps: 'Playbooks, product scenarios, objection handling, success plans, CRM hygiene.',
image: 'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=900&q=80',
},
{
name: 'Product Discovery',
steps: 'Interview templates, JTBD mapping, experiment cards, roadmap debates.',
image: 'https://images.unsplash.com/photo-1483478550801-ceba5fe50e8e?auto=format&fit=crop&w=900&q=80',
},
]
const testimonials = [
{
name: 'Amira Chen',
role: 'VP Engineering, Nimbus',
quote: 'We cut onboarding from weeks to days. The guided flows and assessments keep everyone aligned.',
avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=200&q=80',
},
{
name: 'Luis Ortega',
role: 'Head of Success, Calypso',
quote: 'Playbooks stay fresh automatically. New CSMs ship value on day one.',
avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=200&q=80',
},
]
const logos = [
'https://dummyimage.com/120x40/111827/ffffff&text=Nova',
'https://dummyimage.com/120x40/1f2937/ffffff&text=Helio',
'https://dummyimage.com/120x40/111827/ffffff&text=Arcus',
'https://dummyimage.com/120x40/1f2937/ffffff&text=Vertex',
]
</script>
<template>
<main class="page">
<section class="hero">
<Row :gutter="32" :align="'middle'">
<Col :xs="24" :md="14">
<Typography.Title :level="1" class="hero-title">
Build agentic onboarding that feels bespoke to every role
</Typography.Title>
<Typography.Paragraph class="hero-sub">
AI-led workflows, assessments, and knowledge delivery that adapt to your
stack, your rituals, and your teams - so every new hire ships confidently,
faster.
</Typography.Paragraph>
<Space>
<RouterLink to="/about">
<Button type="primary" size="large">Learn More</Button>
</RouterLink>
<RouterLink to="/onboarding">
<Button size="large">See Onboarding Flows</Button>
</RouterLink>
</Space>
<Divider />
<Row :gutter="16">
<Col v-for="stat in stats" :key="stat.title" :xs="24" :sm="8">
<Card :bordered="false" class="stat-card" hoverable>
<Statistic :title="stat.title" :value="stat.value" />
</Card>
</Col>
</Row>
</Col>
<Col :xs="24" :md="10">
<Card class="hero-card" hoverable :cover="null">
<img :src="heroImage" alt="Team collaborating" class="hero-img" />
<div class="hero-overlay">Adaptive AI playbooks</div>
</Card>
</Col>
</Row>
</section>
<section class="trusted">
<Typography.Text type="secondary">Trusted by modern teams</Typography.Text>
<div class="logo-row">
<img v-for="logo in logos" :key="logo" :src="logo" alt="logo" />
</div>
</section>
<section class="features">
<Typography.Title :level="2">Everything you need to ramp faster</Typography.Title>
<Row :gutter="16">
<Col v-for="feature in features" :key="feature.title" :xs="24" :md="8">
<Card hoverable class="feature-card">
<feature.icon two-tone-color="#8b5cf6" style="font-size: 28px" />
<Typography.Title :level="4">{{ feature.title }}</Typography.Title>
<Typography.Paragraph>{{ feature.description }}</Typography.Paragraph>
<Tag color="purple">Live</Tag>
</Card>
</Col>
</Row>
</section>
<section class="journeys">
<Typography.Title :level="2">Prebuilt journeys, tailored in minutes</Typography.Title>
<Row :gutter="16">
<Col v-for="journey in journeys" :key="journey.name" :xs="24" :md="8">
<Card hoverable class="journey-card">
<template #cover>
<img :alt="journey.name" :src="journey.image" />
</template>
<Typography.Title :level="4">{{ journey.name }}</Typography.Title>
<Typography.Text>{{ journey.steps }}</Typography.Text>
</Card>
</Col>
</Row>
</section>
<section class="testimonials">
<Typography.Title :level="2">What teams are saying</Typography.Title>
<Typography.Text
type="secondary"
style="display: block; text-align: center; margin-bottom: 1rem"
>
(Demo testimonials, real feedback coming soon...)
</Typography.Text>
<Carousel autoplay dot-position="bottom">
<div v-for="t in testimonials" :key="t.name" class="testimonial-slide">
<Card :bordered="false" class="testimonial-card">
<Typography.Paragraph>{{ t.quote }}</Typography.Paragraph>
<Space>
<Avatar :src="t.avatar" size="large" />
<div>
<div class="t-name">{{ t.name }}</div>
<Typography.Text type="secondary">{{ t.role }}</Typography.Text>
</div>
</Space>
</Card>
</div>
</Carousel>
</section>
</main>
</template>
<style scoped>
.page {
padding: 2rem 1.5rem 3rem;
max-width: 1200px;
margin: 0 auto;
}
.hero {
margin-bottom: 2.5rem;
}
.hero-title {
margin-bottom: 1rem;
}
.hero-sub {
font-size: 1.05rem;
color: #cbd5e1;
}
.hero-card {
border: none;
}
.hero-img {
width: 100%;
height: 280px;
object-fit: cover;
border-radius: 8px;
}
.hero-overlay {
margin-top: 0.75rem;
color: #8b5cf6;
font-weight: 600;
}
.stat-card {
background: #0f172a;
border: 1px solid #1f2937;
}
.trusted {
text-align: center;
margin: 2rem 0;
}
.logo-row {
display: flex;
gap: 1.5rem;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.logo-row img {
opacity: 0.8;
height: 32px;
}
.features {
margin: 2.5rem 0;
}
.feature-card {
height: 100%;
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.journeys {
margin: 2.5rem 0;
}
.journey-card {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.testimonials {
margin: 2.5rem 0;
}
.testimonial-slide {
padding: 0 6px;
}
.testimonial-card {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
@media (max-width: 768px) {
.page {
padding: 1.25rem 1rem 2.5rem;
}
.hero-img {
height: 220px;
}
}
</style>

120
src/views/LoginView.vue Normal file
View file

@ -0,0 +1,120 @@
<script setup lang="ts">
import { reactive, computed, onMounted, ref } from 'vue'
import type { VNodeRef } from 'vue'
defineOptions({ name: 'LoginView' })
import { useRouter, useRoute } from 'vue-router'
import { Card, Typography, Form, Input, Button, message } from 'ant-design-vue'
import { useUserStore } from '../stores/userStore'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = computed(() => userStore.loading)
const formRef = ref<VNodeRef | null>(null)
const formState = reactive({
email: '',
password: '',
})
const submit = async () => {
try {
await userStore.login(formState.email, formState.password)
message.success('Login successful')
const redirect = (route.query.redirect as string) || '/onboarding'
router.push(redirect)
} catch (error: unknown) {
let errorMsg = 'Login failed'
if (userStore.error) {
errorMsg = userStore.error
} else if (typeof error === 'string') {
errorMsg = error
} else if (error instanceof Error) {
errorMsg = error.message || 'Login failed'
} else if (typeof error === 'object' && error !== null) {
const errObj = error as { [k: string]: unknown }
const maybeResp = errObj?.response as unknown
if (typeof maybeResp === 'object' && maybeResp !== null) {
const respObj = maybeResp as { data?: unknown }
const data = respObj.data
if (typeof data === 'object' && data !== null) {
const detail = (data as { detail?: unknown }).detail
const msg = (data as { message?: unknown }).message
if (typeof detail === 'string') {
errorMsg = detail
} else if (typeof msg === 'string') {
errorMsg = msg
}
}
}
}
message.error(errorMsg)
}
}
onMounted(async () => {
await userStore.fetchSession()
if (userStore.isAuthenticated) {
const redirect = (route.query.redirect as string) || '/onboarding'
router.replace(redirect)
}
})
</script>
<template>
<div class="auth-page">
<Card class="panel" :bordered="false">
<Typography.Title :level="3">Login</Typography.Title>
<Form :ref="formRef" layout="vertical" :model="formState" @finish="submit">
<Form.Item
label="Email"
name="email"
:rules="[
{ required: true, message: 'Enter your email' },
{
type: 'email',
message: 'Please enter a valid email',
},
]"
>
<Input
v-model:value="formState.email"
type="email"
placeholder="Email address"
:disabled="loading"
/>
</Form.Item>
<Form.Item
label="Password"
name="password"
:rules="[{ required: true, message: 'Enter your password' }]"
>
<Input.Password
v-model:value="formState.password"
placeholder="Password"
:disabled="loading"
/>
</Form.Item>
<Button type="primary" html-type="submit" block :loading="loading">Login</Button>
</Form>
</Card>
</div>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.panel {
max-width: 400px;
width: 100%;
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
</style>

169
src/views/RegisterView.vue Normal file
View file

@ -0,0 +1,169 @@
<script setup lang="ts">
import { reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Card, Typography, Form, Input, Select, Button, message } from 'ant-design-vue'
import { useUserStore } from '../stores/userStore'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = computed(() => userStore.loading)
const formState = reactive({
email: '',
firstName: '',
lastName: '',
password: '',
confirmPassword: '',
role: 'employee' as 'admin' | 'manager' | 'employee',
})
const confirmRules = [
{ required: true, message: 'Confirm your password' },
{
validator: (_: unknown, value: string) =>
value === formState.password
? Promise.resolve()
: Promise.reject('Passwords do not match'),
},
]
const submit = async () => {
try {
await userStore.register({
email_address: formState.email,
password: formState.password,
confirm_password: formState.confirmPassword,
first_name: formState.firstName,
last_name: formState.lastName,
role: formState.role,
})
message.success('Account created')
const redirect = (route.query.redirect as string) || '/onboarding'
router.push(redirect)
} catch (error: unknown) {
let errorMsg = 'Registration failed'
if (userStore.error) {
errorMsg = userStore.error
} else if (typeof error === 'string') {
errorMsg = error
} else if (error instanceof Error) {
errorMsg = error.message || 'Registration failed'
} else if (typeof error === 'object' && error !== null) {
const errObj = error as { [k: string]: unknown }
const maybeResp = errObj?.response as unknown
if (typeof maybeResp === 'object' && maybeResp !== null) {
const respObj = maybeResp as { data?: unknown }
const data = respObj.data
if (typeof data === 'object' && data !== null) {
const detail = (data as { detail?: unknown }).detail
const msg = (data as { message?: unknown }).message
if (typeof detail === 'string') {
errorMsg = detail
} else if (typeof msg === 'string') {
errorMsg = msg
}
}
}
}
message.error(errorMsg)
}
}
onMounted(async () => {
await userStore.fetchSession()
if (userStore.isAuthenticated) {
const redirect = (route.query.redirect as string) || '/onboarding'
router.replace(redirect)
}
})
</script>
<template>
<div class="auth-page">
<Card class="panel" :bordered="false">
<Typography.Title :level="3">Register</Typography.Title>
<Form layout="vertical" :model="formState" @finish="submit">
<Form.Item
label="Email"
name="email"
:rules="[
{ required: true, message: 'Enter your email' },
{
type: 'email',
message: 'Please enter a valid email',
},
]"
>
<Input
v-model:value="formState.email"
type="email"
placeholder="Email address"
:disabled="loading"
/>
</Form.Item>
<Form.Item
label="First name"
name="firstName"
:rules="[{ required: true, message: 'Enter your first name' }]"
>
<Input
v-model:value="formState.firstName"
placeholder="First name"
:disabled="loading"
/>
</Form.Item>
<Form.Item
label="Last name"
name="lastName"
:rules="[{ required: true, message: 'Enter your last name' }]"
>
<Input
v-model:value="formState.lastName"
placeholder="Last name"
:disabled="loading"
/>
</Form.Item>
<Form.Item
label="Password"
name="password"
:rules="[{ required: true, message: 'Create a password' }]"
>
<Input.Password
v-model:value="formState.password"
placeholder="Password"
:disabled="loading"
/>
</Form.Item>
<Form.Item label="Confirm password" name="confirmPassword" :rules="confirmRules">
<Input.Password
v-model:value="formState.confirmPassword"
placeholder="Confirm password"
:disabled="loading"
/>
</Form.Item>
<Form.Item label="Role" name="role">
<Select v-model:value="formState.role" :disabled="loading">
<Select.Option value="employee">Employee</Select.Option>
<Select.Option value="manager">Manager</Select.Option>
<Select.Option value="admin">Admin</Select.Option>
</Select>
</Form.Item>
<Button type="primary" html-type="submit" block :loading="loading">Register</Button>
</Form>
</Card>
</div>
</template>
<style scoped>
.auth-page {
max-width: 520px;
margin: 0 auto;
padding: 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
</style>