Spaces:
Runtime error
Runtime error
whung99 commited on
Commit Β·
e49190e
1
Parent(s): 8717018
Add home screen with voice interface and navigation
Browse files- Voice-first home screen with tap-to-speak MicButton
- Real-time agent reasoning UI showing tool call progression
- Emergency banner for high/emergency urgency detection
- Quick action cards: Doctor, Medicine, Food, Check-in
- Check-in countdown timer with visual progress
- Bottom navigation: Home, Timeline, Doctor, Medicine, About
- App layout with global state provider
- Public assets: icons, placeholders
Built with Mistral Vibe
- app/(app)/layout.tsx +11 -0
- app/(app)/page.tsx +5 -0
- components/sickbuddy/bottom-nav.tsx +50 -0
- components/sickbuddy/home-screen.tsx +272 -0
- public/apple-icon.png +0 -0
- public/icon-dark-32x32.png +0 -0
- public/icon-light-32x32.png +0 -0
- public/icon.svg +26 -0
- public/placeholder-logo.png +0 -0
- public/placeholder-logo.svg +1 -0
- public/placeholder-user.jpg +0 -0
- public/placeholder.jpg +0 -0
- public/placeholder.svg +1 -0
app/(app)/layout.tsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BottomNav } from "@/components/sickbuddy/bottom-nav"
|
| 2 |
+
import { AppProvider } from "@/lib/app-context"
|
| 3 |
+
|
| 4 |
+
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
| 5 |
+
return (
|
| 6 |
+
<AppProvider>
|
| 7 |
+
{children}
|
| 8 |
+
<BottomNav />
|
| 9 |
+
</AppProvider>
|
| 10 |
+
)
|
| 11 |
+
}
|
app/(app)/page.tsx
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { HomeScreen } from "@/components/sickbuddy/home-screen"
|
| 2 |
+
|
| 3 |
+
export default function HomePage() {
|
| 4 |
+
return <HomeScreen />
|
| 5 |
+
}
|
components/sickbuddy/bottom-nav.tsx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import Link from "next/link"
|
| 4 |
+
import { usePathname } from "next/navigation"
|
| 5 |
+
import { Home, ClipboardList, Stethoscope, Pill, Info } from "lucide-react"
|
| 6 |
+
import { cn } from "@/lib/utils"
|
| 7 |
+
|
| 8 |
+
const navItems = [
|
| 9 |
+
{ href: "/", icon: Home, label: "Home" },
|
| 10 |
+
{ href: "/timeline", icon: ClipboardList, label: "Timeline" },
|
| 11 |
+
{ href: "/doctor", icon: Stethoscope, label: "Doctor" },
|
| 12 |
+
{ href: "/medication", icon: Pill, label: "Medicine" },
|
| 13 |
+
{ href: "/about", icon: Info, label: "About" },
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
export function BottomNav() {
|
| 17 |
+
const pathname = usePathname()
|
| 18 |
+
|
| 19 |
+
// Don't show bottom nav on check-in or emergency screens
|
| 20 |
+
if (pathname === "/checkin" || pathname === "/emergency") return null
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<nav
|
| 24 |
+
className="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-[430px] z-50"
|
| 25 |
+
aria-label="Main navigation"
|
| 26 |
+
>
|
| 27 |
+
<div className="flex items-center justify-around border-t border-border bg-background/80 backdrop-blur-xl px-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
| 28 |
+
{navItems.map((item) => {
|
| 29 |
+
const isActive = pathname === item.href
|
| 30 |
+
return (
|
| 31 |
+
<Link
|
| 32 |
+
key={item.href}
|
| 33 |
+
href={item.href}
|
| 34 |
+
className={cn(
|
| 35 |
+
"flex flex-col items-center gap-1 min-w-[60px] min-h-[48px] justify-center rounded-xl px-3 py-1.5 transition-colors duration-200",
|
| 36 |
+
isActive
|
| 37 |
+
? "text-primary"
|
| 38 |
+
: "text-muted-foreground hover:text-foreground"
|
| 39 |
+
)}
|
| 40 |
+
aria-current={isActive ? "page" : undefined}
|
| 41 |
+
>
|
| 42 |
+
<item.icon className="h-5 w-5" />
|
| 43 |
+
<span className="text-[10px] font-medium">{item.label}</span>
|
| 44 |
+
</Link>
|
| 45 |
+
)
|
| 46 |
+
})}
|
| 47 |
+
</div>
|
| 48 |
+
</nav>
|
| 49 |
+
)
|
| 50 |
+
}
|
components/sickbuddy/home-screen.tsx
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect } from "react"
|
| 4 |
+
import Link from "next/link"
|
| 5 |
+
import { ClipboardList, Volume2, Stethoscope, Pill, UtensilsCrossed, Bot, Zap } from "lucide-react"
|
| 6 |
+
import {
|
| 7 |
+
AppShell,
|
| 8 |
+
SickBuddyLogo,
|
| 9 |
+
GlassCard,
|
| 10 |
+
MicButton,
|
| 11 |
+
WaveBars,
|
| 12 |
+
} from "./design-system"
|
| 13 |
+
import { cn } from "@/lib/utils"
|
| 14 |
+
import { VoxtralRecorder } from "@/lib/voxtral"
|
| 15 |
+
import { speak, stopSpeaking } from "@/lib/elevenlabs"
|
| 16 |
+
import { agentAnalyze, analyzeSymptoms, AGENT_TOOL_DISPLAY } from "@/lib/mistral"
|
| 17 |
+
import type { AgentStep } from "@/lib/mistral"
|
| 18 |
+
import { useApp, formatCountdown, countdownProgress } from "@/lib/app-context"
|
| 19 |
+
|
| 20 |
+
type VoiceState = "idle" | "listening" | "processing" | "responding"
|
| 21 |
+
|
| 22 |
+
const actionCards = [
|
| 23 |
+
{ label: "See a Doctor", href: "/doctor", icon: Stethoscope },
|
| 24 |
+
{ label: "Get Medicine", href: "/medication", icon: Pill },
|
| 25 |
+
{ label: "Order Food", href: "/food", icon: UtensilsCrossed },
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
export function HomeScreen() {
|
| 29 |
+
const [voiceState, setVoiceState] = useState<VoiceState>("idle")
|
| 30 |
+
const [displayText, setDisplayText] = useState(
|
| 31 |
+
"Hi! I'm SickBuddy. Tap the microphone and tell me how you're feeling."
|
| 32 |
+
)
|
| 33 |
+
const [isReplaying, setIsReplaying] = useState(false)
|
| 34 |
+
const [agentSteps, setAgentSteps] = useState<AgentStep[]>([])
|
| 35 |
+
const [showAgentSteps, setShowAgentSteps] = useState(false)
|
| 36 |
+
const [agentTime, setAgentTime] = useState(0)
|
| 37 |
+
const recorderRef = useRef<VoxtralRecorder | null>(null)
|
| 38 |
+
const { analysis, setAnalysis, addMessage, conversationHistory, nextCheckInMs } = useApp()
|
| 39 |
+
|
| 40 |
+
useEffect(() => {
|
| 41 |
+
if (analysis?.response) setDisplayText(analysis.response)
|
| 42 |
+
}, [analysis])
|
| 43 |
+
|
| 44 |
+
async function handleMicClick() {
|
| 45 |
+
if (voiceState === "listening") {
|
| 46 |
+
await stopRecording()
|
| 47 |
+
} else if (voiceState === "idle" || voiceState === "responding") {
|
| 48 |
+
await startRecording()
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
async function startRecording() {
|
| 53 |
+
try {
|
| 54 |
+
stopSpeaking()
|
| 55 |
+
recorderRef.current = new VoxtralRecorder()
|
| 56 |
+
await recorderRef.current.startRecording()
|
| 57 |
+
setVoiceState("listening")
|
| 58 |
+
setAgentSteps([])
|
| 59 |
+
setShowAgentSteps(false)
|
| 60 |
+
} catch {
|
| 61 |
+
alert("Please allow microphone access to use SickBuddy.")
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
async function stopRecording() {
|
| 66 |
+
if (!recorderRef.current) return
|
| 67 |
+
setVoiceState("processing")
|
| 68 |
+
try {
|
| 69 |
+
// Voxtral now returns BOTH transcription AND audio understanding
|
| 70 |
+
// (voice quality, coughs, breathing, emotional tone)
|
| 71 |
+
const { text: transcript, audioContext } = await recorderRef.current.stopAndTranscribe()
|
| 72 |
+
addMessage("user", transcript)
|
| 73 |
+
|
| 74 |
+
// Log audio observations for debugging
|
| 75 |
+
if (audioContext) {
|
| 76 |
+
console.log("[Audio Understanding]", audioContext)
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Use agent-based analysis (with tool calling) as primary
|
| 80 |
+
// Audio context gives the agent "ears" β like a real doctor listening
|
| 81 |
+
let result
|
| 82 |
+
try {
|
| 83 |
+
result = await agentAnalyze(transcript, conversationHistory, audioContext)
|
| 84 |
+
if (result.agentSteps) setAgentSteps(result.agentSteps)
|
| 85 |
+
if (result.agentMeta) setAgentTime(result.agentMeta.totalTime)
|
| 86 |
+
} catch {
|
| 87 |
+
console.warn("Agent failed, falling back to simple analysis")
|
| 88 |
+
result = await analyzeSymptoms(transcript, conversationHistory)
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
setAnalysis(result)
|
| 92 |
+
addMessage("assistant", result.response)
|
| 93 |
+
setDisplayText(result.response)
|
| 94 |
+
setVoiceState("responding")
|
| 95 |
+
await speak(result.response)
|
| 96 |
+
} catch {
|
| 97 |
+
setDisplayText("Sorry, I had trouble with that. Please try again.")
|
| 98 |
+
setVoiceState("responding")
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
async function handleReplay() {
|
| 103 |
+
if (isReplaying || !displayText) return
|
| 104 |
+
setIsReplaying(true)
|
| 105 |
+
await speak(displayText)
|
| 106 |
+
setIsReplaying(false)
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const statusText: Record<VoiceState, string> = {
|
| 110 |
+
idle: "Tap and speak...",
|
| 111 |
+
listening: "I'm listening... (tap to stop)",
|
| 112 |
+
processing: "SickBuddy is thinking...",
|
| 113 |
+
responding: "",
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
return (
|
| 117 |
+
<AppShell className="flex flex-col min-h-screen pb-24">
|
| 118 |
+
{/* Top bar */}
|
| 119 |
+
<header className="flex items-center justify-between px-4 py-3">
|
| 120 |
+
<SickBuddyLogo />
|
| 121 |
+
<Link
|
| 122 |
+
href="/timeline"
|
| 123 |
+
className="flex h-10 w-10 items-center justify-center rounded-xl transition-colors duration-200 hover:bg-secondary"
|
| 124 |
+
aria-label="Health Timeline"
|
| 125 |
+
>
|
| 126 |
+
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
| 127 |
+
</Link>
|
| 128 |
+
</header>
|
| 129 |
+
|
| 130 |
+
{/* Emergency banner */}
|
| 131 |
+
{analysis?.urgency === "emergency" && (
|
| 132 |
+
<Link href="/emergency" className="mx-4 mb-2">
|
| 133 |
+
<div className="flex items-center gap-2 rounded-2xl border border-danger/30 bg-danger/10 px-4 py-3 animate-pulse">
|
| 134 |
+
<span>π¨</span>
|
| 135 |
+
<p className="text-sm font-semibold text-danger">Your symptoms need immediate attention</p>
|
| 136 |
+
</div>
|
| 137 |
+
</Link>
|
| 138 |
+
)}
|
| 139 |
+
|
| 140 |
+
{/* Center hero */}
|
| 141 |
+
<div className="flex-1 flex flex-col items-center justify-center px-4 gap-6">
|
| 142 |
+
<MicButton
|
| 143 |
+
state={voiceState === "responding" ? "idle" : voiceState}
|
| 144 |
+
onClick={handleMicClick}
|
| 145 |
+
size={120}
|
| 146 |
+
/>
|
| 147 |
+
|
| 148 |
+
{/* Status */}
|
| 149 |
+
{voiceState === "listening" && (
|
| 150 |
+
<div className="flex flex-col items-center gap-3">
|
| 151 |
+
<p className="text-sm font-medium text-foreground">{statusText.listening}</p>
|
| 152 |
+
<WaveBars />
|
| 153 |
+
</div>
|
| 154 |
+
)}
|
| 155 |
+
{voiceState === "processing" && (
|
| 156 |
+
<div className="flex flex-col items-center gap-3">
|
| 157 |
+
<div className="flex items-center gap-2">
|
| 158 |
+
<Bot className="h-4 w-4 text-primary animate-pulse" />
|
| 159 |
+
<p className="text-sm font-medium text-foreground">Agent is analyzing...</p>
|
| 160 |
+
</div>
|
| 161 |
+
<div className="flex gap-3 mt-1">
|
| 162 |
+
{["π", "π", "π
", "π₯"].map((emoji, i) => (
|
| 163 |
+
<span
|
| 164 |
+
key={emoji}
|
| 165 |
+
className="text-lg opacity-30 animate-pulse"
|
| 166 |
+
style={{ animationDelay: `${i * 0.4}s` }}
|
| 167 |
+
>
|
| 168 |
+
{emoji}
|
| 169 |
+
</span>
|
| 170 |
+
))}
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
|
| 175 |
+
{/* Response / Welcome card */}
|
| 176 |
+
{(voiceState === "idle" || voiceState === "responding") && displayText && (
|
| 177 |
+
<GlassCard className="animate-fade-in-up w-full relative">
|
| 178 |
+
<button
|
| 179 |
+
type="button"
|
| 180 |
+
onClick={handleReplay}
|
| 181 |
+
className={cn(
|
| 182 |
+
"absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-lg hover:bg-secondary transition-colors duration-200",
|
| 183 |
+
isReplaying && "text-primary"
|
| 184 |
+
)}
|
| 185 |
+
aria-label="Replay audio"
|
| 186 |
+
>
|
| 187 |
+
<Volume2 className={cn("h-4 w-4", isReplaying ? "text-primary" : "text-muted-foreground")} />
|
| 188 |
+
</button>
|
| 189 |
+
<p className="text-sm leading-relaxed text-foreground pr-10">{displayText}</p>
|
| 190 |
+
</GlassCard>
|
| 191 |
+
)}
|
| 192 |
+
|
| 193 |
+
{/* Agent Reasoning Steps */}
|
| 194 |
+
{agentSteps.length > 0 && (voiceState === "idle" || voiceState === "responding") && (
|
| 195 |
+
<div className="w-full animate-fade-in-up" style={{ animationDelay: "0.2s" }}>
|
| 196 |
+
<button
|
| 197 |
+
type="button"
|
| 198 |
+
onClick={() => setShowAgentSteps(!showAgentSteps)}
|
| 199 |
+
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors mb-2 px-1"
|
| 200 |
+
>
|
| 201 |
+
<Zap className="h-3 w-3 text-primary" />
|
| 202 |
+
<span>
|
| 203 |
+
Agent used {agentSteps.length} tools
|
| 204 |
+
{agentTime > 0 && ` in ${(agentTime / 1000).toFixed(1)}s`}
|
| 205 |
+
</span>
|
| 206 |
+
<span className="text-muted-foreground/50">{showAgentSteps ? "β²" : "βΌ"}</span>
|
| 207 |
+
</button>
|
| 208 |
+
|
| 209 |
+
{showAgentSteps && (
|
| 210 |
+
<GlassCard className="!p-3 !gap-2 flex flex-col">
|
| 211 |
+
<div className="flex items-center gap-1.5 mb-1">
|
| 212 |
+
<Bot className="h-3.5 w-3.5 text-primary" />
|
| 213 |
+
<span className="text-xs font-semibold text-foreground">Agent Reasoning</span>
|
| 214 |
+
</div>
|
| 215 |
+
{agentSteps.map((step, i) => {
|
| 216 |
+
const display = AGENT_TOOL_DISPLAY[step.tool] || { icon: "π§", label: step.tool }
|
| 217 |
+
return (
|
| 218 |
+
<div key={i} className="flex items-start gap-2 text-xs">
|
| 219 |
+
<span className="flex-shrink-0 mt-0.5">{display.icon}</span>
|
| 220 |
+
<div className="flex-1 min-w-0">
|
| 221 |
+
<span className="font-medium text-foreground">{display.label}</span>
|
| 222 |
+
<span className="text-muted-foreground ml-1.5">{step.summary}</span>
|
| 223 |
+
</div>
|
| 224 |
+
<span className="text-muted-foreground/40 flex-shrink-0 text-[10px] tabular-nums">
|
| 225 |
+
{step.timestamp > 0 ? `${(step.timestamp / 1000).toFixed(1)}s` : ""}
|
| 226 |
+
</span>
|
| 227 |
+
</div>
|
| 228 |
+
)
|
| 229 |
+
})}
|
| 230 |
+
</GlassCard>
|
| 231 |
+
)}
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
</div>
|
| 235 |
+
|
| 236 |
+
{/* Action Cards */}
|
| 237 |
+
<div className="px-4 pb-4">
|
| 238 |
+
<div className="flex gap-3 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-none">
|
| 239 |
+
{actionCards.map((card) => (
|
| 240 |
+
<Link
|
| 241 |
+
key={card.href}
|
| 242 |
+
href={card.href}
|
| 243 |
+
className="flex-shrink-0 flex flex-col items-center gap-2 rounded-2xl border border-border bg-card p-4 w-[120px] transition-all duration-200 hover:bg-secondary hover:border-primary/20"
|
| 244 |
+
>
|
| 245 |
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
| 246 |
+
<card.icon className="h-5 w-5 text-primary" />
|
| 247 |
+
</div>
|
| 248 |
+
<span className="text-xs font-medium text-foreground text-center">{card.label}</span>
|
| 249 |
+
</Link>
|
| 250 |
+
))}
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
{/* Check-in countdown */}
|
| 255 |
+
<div className="px-4 pb-4">
|
| 256 |
+
<Link href="/checkin">
|
| 257 |
+
<GlassCard className="flex flex-col gap-2 !p-3 hover:bg-secondary/50 transition-colors">
|
| 258 |
+
<div className="flex items-center justify-between">
|
| 259 |
+
<p className="text-xs text-muted-foreground">
|
| 260 |
+
Next check-in in <span className="text-foreground font-medium">{formatCountdown(nextCheckInMs)}</span>
|
| 261 |
+
</p>
|
| 262 |
+
<span className="text-xs text-primary">Check in now β</span>
|
| 263 |
+
</div>
|
| 264 |
+
<div className="h-1 w-full rounded-full bg-secondary overflow-hidden">
|
| 265 |
+
<div className="h-full rounded-full bg-primary transition-all duration-500" style={{ width: `${countdownProgress(nextCheckInMs)}%` }} />
|
| 266 |
+
</div>
|
| 267 |
+
</GlassCard>
|
| 268 |
+
</Link>
|
| 269 |
+
</div>
|
| 270 |
+
</AppShell>
|
| 271 |
+
)
|
| 272 |
+
}
|
public/apple-icon.png
ADDED
|
|
public/icon-dark-32x32.png
ADDED
|
|
public/icon-light-32x32.png
ADDED
|
|
public/icon.svg
ADDED
|
|
public/placeholder-logo.png
ADDED
|
public/placeholder-logo.svg
ADDED
|
|
public/placeholder-user.jpg
ADDED
|
public/placeholder.jpg
ADDED
|
public/placeholder.svg
ADDED
|
|