Autosave: 20260501-154034
This commit is contained in:
parent
0e22f33482
commit
068b3eea03
186
frontend/src/components/MobileBottomNav.tsx
Normal file
186
frontend/src/components/MobileBottomNav.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import {
|
||||||
|
mdiAccountCircleOutline,
|
||||||
|
mdiChartTimelineVariant,
|
||||||
|
mdiHeartPulse,
|
||||||
|
mdiLeafCircle,
|
||||||
|
mdiMenu,
|
||||||
|
mdiTargetVariant,
|
||||||
|
mdiViewDashboardOutline,
|
||||||
|
mdiWaterOutline,
|
||||||
|
} from '@mdi/js'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { hasCompletedApexOnboarding } from '../helpers/apexOnboarding'
|
||||||
|
import { hasPermission } from '../helpers/userPermissions'
|
||||||
|
import { useAppSelector } from '../stores/hooks'
|
||||||
|
import BaseIcon from './BaseIcon'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onMenuClick: () => void
|
||||||
|
isMenuOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MobileNavItem = {
|
||||||
|
key: string
|
||||||
|
href?: string
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
matchers: string[]
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRouteActive(pathname: string, matchers: string[]) {
|
||||||
|
return matchers.some(
|
||||||
|
(matcher) => pathname === matcher || pathname.startsWith(`${matcher}/`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemClass(active: boolean, isPulse: boolean) {
|
||||||
|
if (isPulse) {
|
||||||
|
return active
|
||||||
|
? 'bg-[#4A6741] text-white shadow-md dark:bg-emerald-700 dark:text-white'
|
||||||
|
: 'bg-[#EDF4EA] text-[#4A6741] dark:bg-dark-800 dark:text-emerald-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
return active
|
||||||
|
? 'bg-[#EDF4EA] text-[#4A6741] dark:bg-dark-800 dark:text-emerald-300'
|
||||||
|
: 'text-[#7E725E] dark:text-slate-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MobileBottomNav({ onMenuClick, isMenuOpen }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth)
|
||||||
|
const [onboardingComplete, setOnboardingComplete] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOnboardingComplete(hasCompletedApexOnboarding(currentUser?.id))
|
||||||
|
}, [currentUser?.id])
|
||||||
|
|
||||||
|
const focusItem: MobileNavItem =
|
||||||
|
onboardingComplete && hasPermission(currentUser, 'READ_GOALS')
|
||||||
|
? {
|
||||||
|
key: 'goals',
|
||||||
|
href: '/goals/goals-list',
|
||||||
|
label: 'Goals',
|
||||||
|
icon: mdiTargetVariant,
|
||||||
|
matchers: ['/goals'],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
key: 'setup',
|
||||||
|
href: '/onboarding',
|
||||||
|
label: 'Setup',
|
||||||
|
icon: mdiAccountCircleOutline,
|
||||||
|
matchers: ['/onboarding'],
|
||||||
|
}
|
||||||
|
|
||||||
|
let trackingItem: MobileNavItem = {
|
||||||
|
key: 'today',
|
||||||
|
href: '/daily-pulse',
|
||||||
|
label: 'Today',
|
||||||
|
icon: mdiChartTimelineVariant,
|
||||||
|
matchers: ['/daily-pulse'],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPermission(currentUser, 'READ_HEALTH_METRICS')) {
|
||||||
|
trackingItem = {
|
||||||
|
key: 'vitals',
|
||||||
|
href: '/health_metrics/health_metrics-list',
|
||||||
|
label: 'Vitals',
|
||||||
|
icon: mdiChartTimelineVariant,
|
||||||
|
matchers: ['/health_metrics'],
|
||||||
|
}
|
||||||
|
} else if (hasPermission(currentUser, 'READ_CONDITIONS')) {
|
||||||
|
trackingItem = {
|
||||||
|
key: 'care',
|
||||||
|
href: '/conditions/conditions-list',
|
||||||
|
label: 'Care',
|
||||||
|
icon: mdiLeafCircle,
|
||||||
|
matchers: ['/conditions'],
|
||||||
|
}
|
||||||
|
} else if (hasPermission(currentUser, 'READ_HYDRATION_LOGS')) {
|
||||||
|
trackingItem = {
|
||||||
|
key: 'hydrate',
|
||||||
|
href: '/hydration_logs/hydration_logs-list',
|
||||||
|
label: 'Hydrate',
|
||||||
|
icon: mdiWaterOutline,
|
||||||
|
matchers: ['/hydration_logs'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: MobileNavItem[] = [
|
||||||
|
{
|
||||||
|
key: 'home',
|
||||||
|
href: '/dashboard',
|
||||||
|
label: 'Home',
|
||||||
|
icon: mdiViewDashboardOutline,
|
||||||
|
matchers: ['/dashboard'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pulse',
|
||||||
|
href: '/daily-pulse',
|
||||||
|
label: 'Pulse',
|
||||||
|
icon: mdiHeartPulse,
|
||||||
|
matchers: ['/daily-pulse'],
|
||||||
|
},
|
||||||
|
focusItem,
|
||||||
|
trackingItem,
|
||||||
|
{
|
||||||
|
key: 'menu',
|
||||||
|
label: 'Menu',
|
||||||
|
icon: mdiMenu,
|
||||||
|
matchers: [],
|
||||||
|
onClick: onMenuClick,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-30 px-3 pb-3 pt-2 lg:hidden">
|
||||||
|
<div className="mx-auto flex max-w-md items-center gap-1 rounded-3xl border border-stone-200 bg-[#FFF9F0]/95 p-2 shadow-lg backdrop-blur-md dark:border-dark-700 dark:bg-dark-900/95">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.key === 'menu'
|
||||||
|
? isMenuOpen
|
||||||
|
: isRouteActive(router.pathname, item.matchers)
|
||||||
|
const isPulse = item.key === 'pulse'
|
||||||
|
const sharedClassName = [
|
||||||
|
'flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-1 py-2 text-[11px] font-semibold transition-all duration-150',
|
||||||
|
getItemClass(isActive, isPulse),
|
||||||
|
].join(' ')
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<BaseIcon path={item.icon} size={isPulse ? 22 : 20} />
|
||||||
|
<span className="truncate text-center leading-none">{item.label}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (item.href) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={item.href}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={sharedClassName}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isActive}
|
||||||
|
aria-label={item.label}
|
||||||
|
className={sharedClassName}
|
||||||
|
onClick={item.onClick}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
88
frontend/src/helpers/apexOnboarding.ts
Normal file
88
frontend/src/helpers/apexOnboarding.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
export type ApexOnboardingRecordIds = {
|
||||||
|
physicalId?: string;
|
||||||
|
lifestyleId?: string;
|
||||||
|
goalId?: string;
|
||||||
|
conditionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApexOnboardingDraft<T = Record<string, unknown>> = {
|
||||||
|
version: number;
|
||||||
|
lastSavedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
form?: Partial<T>;
|
||||||
|
recordIds?: ApexOnboardingRecordIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const APEX_ONBOARDING_VERSION = 1;
|
||||||
|
|
||||||
|
export function getApexOnboardingStorageKey(userId?: string | null) {
|
||||||
|
return userId
|
||||||
|
? `apex-brain:onboarding:v${APEX_ONBOARDING_VERSION}:${userId}`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readApexOnboardingDraft<
|
||||||
|
T = Record<string, unknown>,
|
||||||
|
>(userId?: string | null): ApexOnboardingDraft<T> | null {
|
||||||
|
if (typeof window === 'undefined' || !userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = getApexOnboardingStorageKey(userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = window.localStorage.getItem(storageKey);
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = JSON.parse(rawValue);
|
||||||
|
|
||||||
|
if (!parsedValue || typeof parsedValue !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue as ApexOnboardingDraft<T>;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read APEX BRAIN onboarding draft:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveApexOnboardingDraft<T>(
|
||||||
|
userId: string | null | undefined,
|
||||||
|
draft: Omit<ApexOnboardingDraft<T>, 'version'>,
|
||||||
|
) {
|
||||||
|
if (typeof window === 'undefined' || !userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageKey = getApexOnboardingStorageKey(userId);
|
||||||
|
const nextDraft: ApexOnboardingDraft<T> = {
|
||||||
|
version: APEX_ONBOARDING_VERSION,
|
||||||
|
...draft,
|
||||||
|
lastSavedAt: draft.lastSavedAt ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify(nextDraft));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save APEX BRAIN onboarding draft:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markApexOnboardingCompleted<T>(
|
||||||
|
userId: string | null | undefined,
|
||||||
|
draft: Omit<ApexOnboardingDraft<T>, 'version'>,
|
||||||
|
) {
|
||||||
|
saveApexOnboardingDraft<T>(userId, {
|
||||||
|
...draft,
|
||||||
|
completedAt: draft.completedAt ?? new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasCompletedApexOnboarding(userId?: string | null) {
|
||||||
|
const draft = readApexOnboardingDraft(userId);
|
||||||
|
return Boolean(draft?.completedAt);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken'
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
import menuNavBar from '../menuNavBar'
|
import menuNavBar from '../menuNavBar'
|
||||||
@ -9,66 +9,88 @@ import NavBar from '../components/NavBar'
|
|||||||
import NavBarItemPlain from '../components/NavBarItemPlain'
|
import NavBarItemPlain from '../components/NavBarItemPlain'
|
||||||
import AsideMenu from '../components/AsideMenu'
|
import AsideMenu from '../components/AsideMenu'
|
||||||
import FooterBar from '../components/FooterBar'
|
import FooterBar from '../components/FooterBar'
|
||||||
|
import MobileBottomNav from '../components/MobileBottomNav'
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Search from '../components/Search';
|
import Search from '../components/Search'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import {findMe, logoutUser} from "../stores/authSlice";
|
import { findMe, logoutUser } from '../stores/authSlice'
|
||||||
|
import { humanize } from '../helpers/humanize'
|
||||||
import {hasPermission} from "../helpers/userPermissions";
|
import { hasPermission } from '../helpers/userPermissions'
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
||||||
permission?: string
|
permission?: string
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LayoutAuthenticated({
|
function getMobilePageTitle(pathname: string) {
|
||||||
children,
|
const knownRoutes = [
|
||||||
|
{ prefix: '/dashboard', title: 'Home' },
|
||||||
permission
|
{ prefix: '/daily-pulse', title: 'Daily Pulse' },
|
||||||
|
{ prefix: '/onboarding', title: 'Onboarding' },
|
||||||
}: Props) {
|
{ prefix: '/goals', title: 'Goals' },
|
||||||
|
{ prefix: '/health_metrics', title: 'Vitals' },
|
||||||
|
{ prefix: '/conditions', title: 'Care' },
|
||||||
|
{ prefix: '/hydration_logs', title: 'Hydration' },
|
||||||
|
{ prefix: '/mood_logs', title: 'Mood' },
|
||||||
|
{ prefix: '/sleep_logs', title: 'Sleep' },
|
||||||
|
{ prefix: '/profile', title: 'Profile' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const matchedRoute = knownRoutes.find((route) => pathname.startsWith(route.prefix))
|
||||||
|
|
||||||
|
if (matchedRoute) {
|
||||||
|
return matchedRoute.title
|
||||||
|
}
|
||||||
|
|
||||||
|
const [firstSegment] = pathname.split('/').filter(Boolean)
|
||||||
|
|
||||||
|
if (!firstSegment) {
|
||||||
|
return 'Home'
|
||||||
|
}
|
||||||
|
|
||||||
|
return humanize(firstSegment.replace(/-/g, ' ').replace(/_/g, ' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LayoutAuthenticated({ children, permission }: Props) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { token, currentUser } = useAppSelector((state) => state.auth)
|
const { token, currentUser } = useAppSelector((state) => state.auth)
|
||||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
const bgColor = useAppSelector((state) => state.style.bgLayoutColor)
|
||||||
|
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||||
|
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
||||||
|
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
||||||
|
|
||||||
let localToken
|
let localToken
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Perform localStorage action
|
|
||||||
localToken = localStorage.getItem('token')
|
localToken = localStorage.getItem('token')
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTokenValid = () => {
|
const isTokenValid = () => {
|
||||||
const token = localStorage.getItem('token');
|
const tokenValue = localStorage.getItem('token')
|
||||||
if (!token) return;
|
|
||||||
const date = new Date().getTime() / 1000;
|
if (!tokenValue) return
|
||||||
const data = jwt.decode(token);
|
|
||||||
if (!data) return;
|
const date = new Date().getTime() / 1000
|
||||||
return date < data.exp;
|
const data = jwt.decode(tokenValue)
|
||||||
};
|
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
return date < data.exp
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(findMe());
|
dispatch(findMe())
|
||||||
if (!isTokenValid()) {
|
if (!isTokenValid()) {
|
||||||
dispatch(logoutUser());
|
dispatch(logoutUser())
|
||||||
router.push('/login');
|
router.push('/login')
|
||||||
}
|
}
|
||||||
}, [token, localToken]);
|
}, [token, localToken])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!permission || !currentUser) return;
|
if (!permission || !currentUser) return
|
||||||
|
|
||||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
if (!hasPermission(currentUser, permission)) router.push('/error')
|
||||||
}, [currentUser, permission]);
|
}, [currentUser, permission])
|
||||||
|
|
||||||
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
|
||||||
|
|
||||||
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
|
||||||
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChangeStart = () => {
|
const handleRouteChangeStart = () => {
|
||||||
@ -78,22 +100,20 @@ export default function LayoutAuthenticated({
|
|||||||
|
|
||||||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
router.events.on('routeChangeStart', handleRouteChangeStart)
|
||||||
|
|
||||||
// If the component is unmounted, unsubscribe
|
|
||||||
// from the event with the `off` method:
|
|
||||||
return () => {
|
return () => {
|
||||||
router.events.off('routeChangeStart', handleRouteChangeStart)
|
router.events.off('routeChangeStart', handleRouteChangeStart)
|
||||||
}
|
}
|
||||||
}, [router.events, dispatch])
|
}, [router.events, dispatch])
|
||||||
|
|
||||||
|
|
||||||
const layoutAsidePadding = 'xl:pl-60'
|
const layoutAsidePadding = 'xl:pl-60'
|
||||||
|
const mobilePageTitle = getMobilePageTitle(router.pathname)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||||
<div
|
<div
|
||||||
className={`${layoutAsidePadding} ${
|
className={`${layoutAsidePadding} ${
|
||||||
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||||
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
} pt-14 pb-24 min-h-screen w-screen transition-position lg:w-auto lg:pb-0 ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||||
>
|
>
|
||||||
<NavBar
|
<NavBar
|
||||||
menu={menuNavBar}
|
menu={menuNavBar}
|
||||||
@ -105,13 +125,23 @@ export default function LayoutAuthenticated({
|
|||||||
>
|
>
|
||||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center px-1 lg:hidden">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[10px] font-semibold uppercase tracking-[0.28em] text-[#8A7C66] dark:text-slate-400">
|
||||||
|
APEX BRAIN
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-sm font-semibold text-[#1C1C1C] dark:text-white">
|
||||||
|
{mobilePageTitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<NavBarItemPlain
|
<NavBarItemPlain
|
||||||
display="hidden lg:flex xl:hidden"
|
display="hidden lg:flex xl:hidden"
|
||||||
onClick={() => setIsAsideLgActive(true)}
|
onClick={() => setIsAsideLgActive(true)}
|
||||||
>
|
>
|
||||||
<BaseIcon path={mdiMenu} size="24" />
|
<BaseIcon path={mdiMenu} size="24" />
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
<NavBarItemPlain useMargin>
|
<NavBarItemPlain display="hidden lg:flex" useMargin>
|
||||||
<Search />
|
<Search />
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
</NavBar>
|
</NavBar>
|
||||||
@ -122,7 +152,13 @@ export default function LayoutAuthenticated({
|
|||||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
<div className="hidden lg:block">
|
||||||
|
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||||
|
</div>
|
||||||
|
<MobileBottomNav
|
||||||
|
isMenuOpen={isAsideMobileExpanded}
|
||||||
|
onMenuClick={() => setIsAsideMobileExpanded((currentValue) => !currentValue)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,7 +5,17 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Home',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/daily-pulse',
|
||||||
|
label: 'Daily Pulse',
|
||||||
|
icon: 'mdiHeartPulse' in icon ? icon['mdiHeartPulse' as keyof typeof icon] : icon.mdiChartTimelineVariant,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/onboarding',
|
||||||
|
label: 'Onboarding',
|
||||||
|
icon: icon.mdiAccount,
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
1399
frontend/src/pages/daily-pulse.tsx
Normal file
1399
frontend/src/pages/daily-pulse.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,166 +1,399 @@
|
|||||||
|
import {
|
||||||
import React, { useEffect, useState } from 'react';
|
mdiArrowTopRight,
|
||||||
import type { ReactElement } from 'react';
|
mdiCheckCircleOutline,
|
||||||
|
mdiHeartPulse,
|
||||||
|
mdiLeafCircle,
|
||||||
|
mdiTargetVariant,
|
||||||
|
mdiTrophyOutline,
|
||||||
|
mdiWaterOutline,
|
||||||
|
mdiWeatherNight,
|
||||||
|
} from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import type { ReactElement } from 'react';
|
||||||
import CardBox from '../components/CardBox';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const promiseCards = [
|
||||||
|
{
|
||||||
|
title: 'Daily Pulse check-ins',
|
||||||
|
description: 'Save water, mood, sleep, and weight in one elegant flow that already connects to the app’s deeper record screens.',
|
||||||
|
icon: mdiHeartPulse,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Premium daily ring',
|
||||||
|
description: 'Get a calm summary of what has been captured today, with a clear score that encourages consistency without overwhelm.',
|
||||||
|
icon: mdiTrophyOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Goals in context',
|
||||||
|
description: 'Keep active goals visible beside today’s inputs so actions feel connected to long-term outcomes.',
|
||||||
|
icon: mdiTargetVariant,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const featureChips = [
|
||||||
|
'Fitness',
|
||||||
|
'Nutrition',
|
||||||
|
'Medical',
|
||||||
|
'Mental Wellness',
|
||||||
|
'Sleep',
|
||||||
|
'Hydration',
|
||||||
|
'Longevity',
|
||||||
|
'Family & Teams',
|
||||||
|
'Health Vault',
|
||||||
|
'Reports & Exports',
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflowSteps = [
|
||||||
|
{
|
||||||
|
step: '01',
|
||||||
|
title: 'Capture your essentials',
|
||||||
|
description: 'Log hydration, mood, sleep, and weight from one mobile-first workspace.',
|
||||||
|
icon: mdiWaterOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: '02',
|
||||||
|
title: 'See your score instantly',
|
||||||
|
description: 'The Daily Pulse ring turns raw logging into a clear signal for the day.',
|
||||||
|
icon: mdiCheckCircleOutline,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: '03',
|
||||||
|
title: 'Drill into full records',
|
||||||
|
description: 'Jump directly into detailed CRUD screens for trends, edits, exports, and history.',
|
||||||
|
icon: mdiArrowTopRight,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Starter() {
|
export default function Starter() {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
|
||||||
src: undefined,
|
|
||||||
photographer: undefined,
|
|
||||||
photographer_url: undefined,
|
|
||||||
})
|
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('video');
|
|
||||||
const [contentPosition, setContentPosition] = useState('right');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'APEX BRAIN'
|
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('APEX BRAIN')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div style={{ backgroundColor: '#F5F0E8', color: '#1C1C1C' }}>
|
||||||
<div
|
<header className="relative overflow-hidden border-b" style={{ borderColor: '#E1D6C5' }}>
|
||||||
className={`flex ${
|
<div
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
className="absolute inset-x-0 top-0 h-[28rem]"
|
||||||
} min-h-screen w-full`}
|
style={{
|
||||||
>
|
background:
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
'radial-gradient(circle at top left, rgba(201,168,76,0.18) 0%, rgba(201,168,76,0) 48%), radial-gradient(circle at top right, rgba(74,103,65,0.16) 0%, rgba(74,103,65,0) 44%)',
|
||||||
? imageBlock(illustrationImage)
|
}}
|
||||||
: null}
|
/>
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<div className="relative mx-auto max-w-7xl px-6 py-6 lg:px-10">
|
||||||
? videoBlock(illustrationVideo)
|
<nav className="flex flex-wrap items-center justify-between gap-4">
|
||||||
: null}
|
<Link href="/" className="inline-flex items-center gap-3 text-lg font-semibold tracking-[0.08em]" style={{ color: '#1C1C1C' }}>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<span
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
className="flex h-12 w-12 items-center justify-center rounded-2xl"
|
||||||
<CardBoxComponentTitle title="Welcome to your APEX BRAIN app!"/>
|
style={{ backgroundColor: '#4A6741', color: '#F5F0E8' }}
|
||||||
|
>
|
||||||
<div className="space-y-3">
|
<BaseIcon path={mdiTrophyOutline} size={24} />
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
</span>
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
APEX BRAIN
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm font-semibold">
|
||||||
|
<Link href="/daily-pulse" className="rounded-full border px-4 py-2 transition hover:-translate-y-0.5" style={{ borderColor: '#D6C8B4', color: '#4A6741' }}>
|
||||||
|
Daily Pulse
|
||||||
|
</Link>
|
||||||
|
<Link href="/onboarding" className="rounded-full border px-4 py-2 transition hover:-translate-y-0.5" style={{ borderColor: '#D6C8B4', color: '#4A6741' }}>
|
||||||
|
Onboarding
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard" className="rounded-full border px-4 py-2 transition hover:-translate-y-0.5" style={{ borderColor: '#D6C8B4', color: '#4A6741' }}>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link href="/login" className="rounded-full px-4 py-2 text-white transition hover:-translate-y-0.5" style={{ backgroundColor: '#4A6741' }}>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="grid gap-12 pb-14 pt-12 lg:grid-cols-[1.1fr_0.9fr] lg:items-center lg:pb-20 lg:pt-20">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="mb-5 inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold"
|
||||||
|
style={{ borderColor: '#D6C8B4', color: '#4A6741', backgroundColor: 'rgba(255,255,255,0.56)' }}
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiLeafCircle} size={18} />
|
||||||
|
Your Complete Health Operating System
|
||||||
|
</div>
|
||||||
|
<h1 className="max-w-3xl text-5xl font-semibold tracking-tight md:text-6xl" style={{ color: '#1C1C1C' }}>
|
||||||
|
The warm, premium wellness home for your entire life.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-2xl text-lg leading-8" style={{ color: '#4C5544' }}>
|
||||||
|
APEX BRAIN brings health, fitness, medical tracking, habits, sleep, hydration, and longevity into one calm, trustworthy experience — with a polished first slice you can use right now.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-semibold text-white transition hover:-translate-y-0.5"
|
||||||
|
style={{ backgroundColor: '#4A6741' }}
|
||||||
|
>
|
||||||
|
Create My Profile
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center justify-center rounded-full border px-6 py-3 text-sm font-semibold transition hover:-translate-y-0.5"
|
||||||
|
style={{ borderColor: '#D6C8B4', color: '#4A6741', backgroundColor: 'rgba(255,255,255,0.66)' }}
|
||||||
|
>
|
||||||
|
I Already Have an Account
|
||||||
|
</Link>
|
||||||
|
<Link href="/onboarding" className="text-sm font-semibold" style={{ color: '#4A6741' }}>
|
||||||
|
Continue guided onboarding →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-[24px] border p-4" style={{ borderColor: '#E1D6C5', backgroundColor: 'rgba(255,255,255,0.72)' }}>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em]" style={{ color: '#7E725E' }}>
|
||||||
|
Aesthetics
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-6" style={{ color: '#4C5544' }}>
|
||||||
|
Cream backgrounds, forest green accents, and soft gold highlights for a grounded, luxury feel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border p-4" style={{ borderColor: '#E1D6C5', backgroundColor: 'rgba(255,255,255,0.72)' }}>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em]" style={{ color: '#7E725E' }}>
|
||||||
|
Mobile-first
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-6" style={{ color: '#4C5544' }}>
|
||||||
|
Designed to feel just as refined on iPhone, Android, and desktop screens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] border p-4" style={{ borderColor: '#E1D6C5', backgroundColor: 'rgba(255,255,255,0.72)' }}>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em]" style={{ color: '#7E725E' }}>
|
||||||
|
First live slice
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-6" style={{ color: '#4C5544' }}>
|
||||||
|
Daily Pulse is implemented now as the first real workflow, not just a mockup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:justify-self-end">
|
||||||
|
<div className="mx-auto max-w-md rounded-[34px] border p-4 shadow-[0_30px_80px_rgba(74,103,65,0.12)]" style={{ borderColor: '#D8CBB7', backgroundColor: 'rgba(255,255,255,0.7)' }}>
|
||||||
|
<div className="rounded-[30px] border p-5" style={{ borderColor: '#E1D6C5', backgroundColor: '#FFF9F0' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.18em]" style={{ color: '#7E725E' }}>
|
||||||
|
Daily Pulse preview
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-2xl font-semibold" style={{ color: '#1C1C1C' }}>
|
||||||
|
75 / 100 today
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em]" style={{ backgroundColor: 'rgba(74,103,65,0.12)', color: '#2F5A35' }}>
|
||||||
|
live now
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-[22px] border p-4" style={{ borderColor: '#E5DAC9', backgroundColor: 'rgba(255,255,255,0.72)' }}>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#4A6741' }}>
|
||||||
|
<BaseIcon path={mdiWaterOutline} size={20} />
|
||||||
|
Hydration
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">1,250 ml</div>
|
||||||
|
<p className="mt-2 text-sm" style={{ color: '#6A6A55' }}>2 entries captured today</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[22px] border p-4" style={{ borderColor: '#E5DAC9', backgroundColor: 'rgba(255,255,255,0.72)' }}>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#4A6741' }}>
|
||||||
|
<BaseIcon path={mdiWeatherNight} size={20} />
|
||||||
|
Sleep
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">7h 38m</div>
|
||||||
|
<p className="mt-2 text-sm" style={{ color: '#6A6A55' }}>Quality 8 / 10</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[22px] border p-4" style={{ borderColor: '#E5DAC9', backgroundColor: 'rgba(255,255,255,0.72)' }}>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#4A6741' }}>
|
||||||
|
<BaseIcon path={mdiHeartPulse} size={20} />
|
||||||
|
Mood
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">8 / 10</div>
|
||||||
|
<p className="mt-2 text-sm" style={{ color: '#6A6A55' }}>Calm • Focused • Optimistic</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[22px] border p-4" style={{ borderColor: '#E5DAC9', backgroundColor: 'rgba(255,255,255,0.72)' }}>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#4A6741' }}>
|
||||||
|
<BaseIcon path={mdiTargetVariant} size={20} />
|
||||||
|
Goal focus
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-lg font-semibold">Build better daily habits</div>
|
||||||
|
<p className="mt-2 text-sm" style={{ color: '#6A6A55' }}>One ritual connected to the rest of the app.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/daily-pulse" className="mt-5 inline-flex items-center gap-2 text-sm font-semibold" style={{ color: '#4A6741' }}>
|
||||||
|
Open the Daily Pulse slice
|
||||||
|
<BaseIcon path={mdiArrowTopRight} size={18} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<BaseButtons>
|
</header>
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<main>
|
||||||
</CardBox>
|
<section className="mx-auto max-w-7xl px-6 py-16 lg:px-10 lg:py-20">
|
||||||
</div>
|
<div className="max-w-3xl">
|
||||||
</div>
|
<div className="text-sm font-semibold uppercase tracking-[0.2em]" style={{ color: '#7E725E' }}>
|
||||||
</SectionFullScreen>
|
FIRST DELIVERY
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
</div>
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
<h2 className="mt-4 text-4xl font-semibold tracking-tight" style={{ color: '#1C1C1C' }}>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
A complete first win, not just a landing page.
|
||||||
Privacy Policy
|
</h2>
|
||||||
</Link>
|
<p className="mt-4 text-lg leading-8" style={{ color: '#4C5544' }}>
|
||||||
</div>
|
The initial APEX BRAIN experience is centered around a premium Daily Pulse workflow: create inputs, see a summary ring, review recent activity, and jump into full detail screens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
<div className="mt-10 grid gap-5 lg:grid-cols-3">
|
||||||
|
{promiseCards.map((card) => (
|
||||||
|
<div key={card.title} className="rounded-[28px] border p-6 shadow-sm" style={{ borderColor: '#E1D6C5', backgroundColor: '#FFF9F0' }}>
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl" style={{ backgroundColor: '#F1E7D8', color: '#4A6741' }}>
|
||||||
|
<BaseIcon path={card.icon} size={26} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 text-xl font-semibold" style={{ color: '#1C1C1C' }}>
|
||||||
|
{card.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7" style={{ color: '#4C5544' }}>
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="border-y" style={{ borderColor: '#E1D6C5', backgroundColor: '#EFE6DB' }}>
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-16 lg:px-10 lg:py-20">
|
||||||
|
<div className="grid gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-[0.2em]" style={{ color: '#7E725E' }}>
|
||||||
|
How the slice works
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-4xl font-semibold tracking-tight" style={{ color: '#1C1C1C' }}>
|
||||||
|
One calm ritual that already feels like a real health product.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg leading-8" style={{ color: '#4C5544' }}>
|
||||||
|
Instead of scattering the experience across dozens of unfinished screens, the first release centers attention on the one journey users repeat every day.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{workflowSteps.map((item) => (
|
||||||
|
<div key={item.step} className="rounded-[28px] border p-5" style={{ borderColor: '#D6C8B4', backgroundColor: '#FFF9F0' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-[0.18em]" style={{ color: '#7E725E' }}>
|
||||||
|
{item.step}
|
||||||
|
</div>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-2xl" style={{ backgroundColor: '#F1E7D8', color: '#4A6741' }}>
|
||||||
|
<BaseIcon path={item.icon} size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-5 text-lg font-semibold" style={{ color: '#1C1C1C' }}>
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 text-sm leading-7" style={{ color: '#4C5544' }}>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mx-auto max-w-7xl px-6 py-16 lg:px-10 lg:py-20">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-[0.2em]" style={{ color: '#7E725E' }}>
|
||||||
|
What comes next
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-4xl font-semibold tracking-tight" style={{ color: '#1C1C1C' }}>
|
||||||
|
The foundation is designed to expand into the full APEX BRAIN vision.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg leading-8" style={{ color: '#4C5544' }}>
|
||||||
|
The app already has the building blocks for a much broader health platform. Daily Pulse is the first polished slice that can grow into the complete ecosystem below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
|
{featureChips.map((chip) => (
|
||||||
|
<span key={chip} className="rounded-full border px-4 py-2 text-sm font-semibold" style={{ borderColor: '#D6C8B4', color: '#4A6741', backgroundColor: '#FFF9F0' }}>
|
||||||
|
{chip}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 rounded-[32px] border p-8" style={{ borderColor: '#D8CBB7', backgroundColor: '#FFF9F0' }}>
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-[0.2em]" style={{ color: '#7E725E' }}>
|
||||||
|
Ready to continue?
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-3xl font-semibold" style={{ color: '#1C1C1C' }}>
|
||||||
|
Start with the branded slice, then tell us which area should expand next.
|
||||||
|
</h3>
|
||||||
|
<p className="mt-3 max-w-2xl text-base leading-7" style={{ color: '#4C5544' }}>
|
||||||
|
You can create an account, start the guided onboarding flow, sign in to the admin interface, or go straight to Daily Pulse once your setup feels right.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link
|
||||||
|
href="/onboarding"
|
||||||
|
className="inline-flex items-center justify-center rounded-full px-6 py-3 text-sm font-semibold text-white transition hover:-translate-y-0.5"
|
||||||
|
style={{ backgroundColor: '#4A6741' }}
|
||||||
|
>
|
||||||
|
Start onboarding
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/daily-pulse"
|
||||||
|
className="inline-flex items-center justify-center rounded-full border px-6 py-3 text-sm font-semibold transition hover:-translate-y-0.5"
|
||||||
|
style={{ borderColor: '#D6C8B4', color: '#4A6741' }}
|
||||||
|
>
|
||||||
|
Open Daily Pulse
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-flex items-center justify-center rounded-full border px-6 py-3 text-sm font-semibold transition hover:-translate-y-0.5"
|
||||||
|
style={{ borderColor: '#D6C8B4', color: '#4A6741' }}
|
||||||
|
>
|
||||||
|
Admin Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="border-t" style={{ borderColor: '#E1D6C5' }}>
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-6 py-6 text-sm lg:flex-row lg:items-center lg:justify-between lg:px-10" style={{ color: '#6A6A55' }}>
|
||||||
|
<p>© 2026 APEX BRAIN. Calm, premium health management in one place.</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-4 font-semibold">
|
||||||
|
<Link href="/register" style={{ color: '#4A6741' }}>
|
||||||
|
Create profile
|
||||||
|
</Link>
|
||||||
|
<Link href="/login" style={{ color: '#4A6741' }}>
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link href="/onboarding" style={{ color: '#4A6741' }}>
|
||||||
|
Onboarding
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard" style={{ color: '#4A6741' }}>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
import {toast, ToastContainer} from "react-toastify";
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
||||||
|
import { hasCompletedApexOnboarding } from '../helpers/apexOnboarding';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -64,10 +65,15 @@ export default function Login() {
|
|||||||
}, [token, dispatch]);
|
}, [token, dispatch]);
|
||||||
// Redirect to dashboard if user is logged in
|
// Redirect to dashboard if user is logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.id) {
|
if (!currentUser?.id) {
|
||||||
router.push('/dashboard');
|
return;
|
||||||
}
|
}
|
||||||
}, [currentUser?.id, router]);
|
|
||||||
|
const shouldStartOnboarding =
|
||||||
|
!hasCompletedApexOnboarding(currentUser.id) && !currentUser.firstName;
|
||||||
|
|
||||||
|
router.push(shouldStartOnboarding ? '/onboarding' : '/dashboard');
|
||||||
|
}, [currentUser?.firstName, currentUser?.id, router]);
|
||||||
// Show error message if there is one
|
// Show error message if there is one
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (errorMessage){
|
if (errorMessage){
|
||||||
|
|||||||
1626
frontend/src/pages/onboarding.tsx
Normal file
1626
frontend/src/pages/onboarding.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user