feat: AI-powered login page with daily greeting and animated blob background

- Add public /api/greeting endpoint that generates a daily AI greeting (cached per day)
- Replace static hero text with dynamic AI greeting (ChatGPT/Claude style)
- Add Stripe-style animated gradient blobs with 14 daily-rotating color palettes
- Use Plus Jakarta Sans font and Sparkles icon for modern AI look
- Update subtitle to "Driven by multiple AI agents"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-18 00:14:32 -04:00
parent 7f19e38fc1
commit 3e919ec1c5
6 changed files with 223 additions and 37 deletions

View File

@@ -5,6 +5,7 @@ import { errorHandler } from "./middlewares/error.middleware";
import { apiLogger } from "./middlewares/logger.middleware";
import authRoutes from "./routes/auth";
import twilioWebhookRoutes from "./routes/twilio-webhooks";
import greetingRoutes from "./routes/greeting";
import { authenticateJWT } from "./middlewares/auth.middleware";
import dotenv from "dotenv";
import { startBackupCron } from "./cron/backupCheck";
@@ -73,6 +74,7 @@ app.use(
app.use("/uploads", express.static(path.join(process.cwd(), "uploads")));
app.use("/api/auth", authRoutes);
app.use("/api/greeting", greetingRoutes);
// Twilio webhooks are public — Twilio sends no JWT token
app.use("/api/twilio", express.urlencoded({ extended: false }), twilioWebhookRoutes);
// All other API routes require JWT

View File

@@ -0,0 +1,61 @@
import express, { Request, Response } from "express";
import { prisma as db } from "@repo/db/client";
import { resolveAiProvider, getLlm } from "../ai/llm-factory";
const router = express.Router();
const FALLBACK_GREETINGS = [
"How can I help you today?",
"What can I do for you today?",
"Ready when you are.",
"What's on your mind today?",
"Let's get started.",
];
let cachedGreeting: { text: string; date: string } | null = null;
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
router.get("/", async (_req: Request, res: Response): Promise<any> => {
try {
const today = todayKey();
if (cachedGreeting && cachedGreeting.date === today) {
return res.status(200).json({ greeting: cachedGreeting.text });
}
const aiSettings = await db.aiSettings.findFirst();
const activeAi = aiSettings ? resolveAiProvider(aiSettings) : null;
if (!activeAi) {
const fallback = FALLBACK_GREETINGS[Math.floor(Math.random() * FALLBACK_GREETINGS.length)];
return res.status(200).json({ greeting: fallback });
}
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
const result = await llm.invoke([
{
role: "system",
content: "Generate a single short AI greeting, similar to how ChatGPT or Claude greets users. Keep it to one short sentence. Examples of the style: 'How can I help you today?', 'What's on your mind?', 'Ready when you are.', 'What can I do for you today?'. Be warm but concise. Do not use emojis. Do not mention names. Vary each greeting.",
},
{
role: "user",
content: "Generate a short greeting.",
},
]);
const greeting = typeof result.content === "string"
? result.content.trim()
: FALLBACK_GREETINGS[0]!;
cachedGreeting = { text: greeting, date: today };
return res.status(200).json({ greeting });
} catch {
const fallback = FALLBACK_GREETINGS[Math.floor(Math.random() * FALLBACK_GREETINGS.length)];
return res.status(200).json({ greeting: fallback });
}
});
export default router;

View File

@@ -4,6 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>My Dental Office Management</title>
</head>
<body>

View File

@@ -65,6 +65,43 @@
@apply font-sans antialiased bg-background text-foreground;
}
}
@keyframes blob-float {
0%, 100% {
transform: translate(0, 0) scale(1);
border-radius: 40% 60% 70% 30% / 40% 50% 60% 50%;
}
25% {
transform: translate(80px, -60px) scale(1.25);
border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%;
}
50% {
transform: translate(-50px, 50px) scale(0.85);
border-radius: 30% 60% 40% 70% / 50% 60% 30% 60%;
}
75% {
transform: translate(40px, 30px) scale(1.1);
border-radius: 50% 40% 60% 30% / 40% 70% 50% 60%;
}
}
@keyframes blob-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.login-blob {
animation: blob-float 25s ease-in-out infinite;
}
.login-blob-container {
animation: blob-rotate 50s linear infinite;
}
.animation-delay-2000 {
animation-delay: 5s;
}
.animation-delay-4000 {
animation-delay: 10s;
}
.day-picker-small-scale select {
@apply text-sm text-gray-900 bg-white border border-gray-300 rounded-md focus:outline-none focus:border-blue-600 focus:ring-1 focus:ring-blue-600;
height: 32px; /* Fixed height: ~h-8 */

View File

@@ -1,6 +1,6 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import {
@@ -14,15 +14,49 @@ import {
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Card } from "@/components/ui/card";
import { CheckCircle, Torus } from "lucide-react";
import { Sparkles } from "lucide-react";
import { CheckedState } from "@radix-ui/react-checkbox";
import LoadingScreen from "@/components/ui/LoadingScreen";
import { useLocation } from "wouter";
import { LoginFormValues, loginSchema } from "@repo/db/types";
const DAILY_PALETTES = [
{ bg: "#f8fafc", blobs: ["#a78bfa", "#38bdf8", "#f472b6", "#fb923c"], textColor: "#1e293b" },
{ bg: "#faf5ff", blobs: ["#c084fc", "#22d3ee", "#fb7185", "#facc15"], textColor: "#1e1b4b" },
{ bg: "#f0f9ff", blobs: ["#60a5fa", "#a78bfa", "#34d399", "#f97316"], textColor: "#0c4a6e" },
{ bg: "#fdf4ff", blobs: ["#e879f9", "#38bdf8", "#fbbf24", "#4ade80"], textColor: "#4a044e" },
{ bg: "#f0fdf4", blobs: ["#4ade80", "#818cf8", "#fb923c", "#f472b6"], textColor: "#052e16" },
{ bg: "#fffbeb", blobs: ["#fbbf24", "#f87171", "#a78bfa", "#22d3ee"], textColor: "#78350f" },
{ bg: "#f8fafc", blobs: ["#38bdf8", "#e879f9", "#34d399", "#fb923c"], textColor: "#0f172a" },
{ bg: "#fef2f2", blobs: ["#fb7185", "#a78bfa", "#38bdf8", "#4ade80"], textColor: "#7f1d1d" },
{ bg: "#ecfdf5", blobs: ["#34d399", "#818cf8", "#f472b6", "#facc15"], textColor: "#064e3b" },
{ bg: "#f5f3ff", blobs: ["#8b5cf6", "#f472b6", "#22d3ee", "#fb923c"], textColor: "#2e1065" },
{ bg: "#fff7ed", blobs: ["#fb923c", "#a78bfa", "#22d3ee", "#4ade80"], textColor: "#7c2d12" },
{ bg: "#f0f9ff", blobs: ["#22d3ee", "#c084fc", "#f97316", "#34d399"], textColor: "#0c4a6e" },
{ bg: "#fdf2f8", blobs: ["#f472b6", "#60a5fa", "#facc15", "#34d399"], textColor: "#831843" },
{ bg: "#f8fafc", blobs: ["#818cf8", "#f97316", "#22d3ee", "#f472b6"], textColor: "#1e293b" },
];
function getDailyPalette() {
const now = new Date();
const start = new Date(now.getFullYear(), 0, 0);
const dayOfYear = Math.floor((now.getTime() - start.getTime()) / 86400000);
return DAILY_PALETTES[dayOfYear % DAILY_PALETTES.length]!;
}
export default function AuthPage() {
const { isLoading, user, loginMutation } = useAuth();
const [, navigate] = useLocation();
const [greeting, setGreeting] = useState("How can I help you today?");
const palette = getDailyPalette();
useEffect(() => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL_BACKEND ?? "";
fetch(`${API_BASE_URL}/api/greeting`)
.then((r) => r.json())
.then((data) => { if (data.greeting) setGreeting(data.greeting); })
.catch(() => {});
}, []);
const loginForm = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
@@ -48,17 +82,16 @@ export default function AuthPage() {
}, [user, navigate]);
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 shadow-lg rounded-lg overflow-hidden">
<div className="min-h-screen bg-gray-100 flex items-center justify-center" style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 shadow-xl rounded-2xl overflow-hidden">
{/* Auth Form */}
<Card className="p-6 bg-white">
<Card className="p-8 bg-white">
<div className="mb-10 text-center">
<h1 className="text-3xl font-medium text-primary mb-2">
<h1 className="text-3xl font-bold text-primary mb-3 tracking-tight">
My Dental Office Management
</h1>
<p className="text-gray-600">
{" "}
Comprehensive Practice Management System
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">
Driven by multiple AI agents
</p>
</div>
@@ -132,35 +165,73 @@ export default function AuthPage() {
</Form>
</Card>
{/* Hero Section */}
<div className="md:block bg-primary p-8 text-white flex flex-col justify-center">
<div className="flex justify-center mb-6">
<div className="w-16 h-16 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
<Torus className="h-8 w-8" />
</div>
{/* Hero Section — Stripe-style vibrant gradient mesh */}
<div
className="relative overflow-hidden md:flex p-8 flex flex-col justify-center items-center min-h-[320px]"
style={{ background: palette.bg }}
>
{/* Animated blobs */}
<div className="absolute inset-0 login-blob-container">
<div
className="absolute opacity-70 login-blob"
style={{
width: 300, height: 300,
top: "-15%", left: "-10%",
background: palette.blobs[0],
filter: "blur(70px)",
}}
/>
<div
className="absolute opacity-70 login-blob animation-delay-2000"
style={{
width: 350, height: 350,
top: "50%", right: "-15%",
background: palette.blobs[1],
filter: "blur(70px)",
}}
/>
<div
className="absolute opacity-70 login-blob animation-delay-4000"
style={{
width: 280, height: 280,
bottom: "-15%", left: "15%",
background: palette.blobs[2],
filter: "blur(70px)",
}}
/>
</div>
{/* 4th accent blob */}
<div
className="absolute opacity-50 login-blob animation-delay-2000"
style={{
width: 220, height: 220,
top: "15%", right: "20%",
background: palette.blobs[3],
filter: "blur(60px)",
}}
/>
{/* Content */}
<div className="relative z-10 flex flex-col items-center">
<div className="flex justify-center mb-8">
<div
className="w-20 h-20 backdrop-blur-md rounded-full flex items-center justify-center border"
style={{
background: "rgba(255,255,255,0.25)",
borderColor: "rgba(255,255,255,0.4)",
}}
>
<Sparkles className="h-10 w-10" style={{ color: palette.textColor }} />
</div>
</div>
<p
className="text-center text-2xl leading-relaxed font-bold tracking-tight"
style={{ color: palette.textColor }}
>
{greeting}
</p>
</div>
<p className="mb-6 text-center text-white text-opacity-80">
The complete solution for dental practice management. Streamline
your patient records, appointments, and more.
</p>
<ul className="space-y-4">
<li className="flex items-center">
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
<span>Easily manage patient records</span>
</li>
<li className="flex items-center">
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
<span>Track patient insurance information</span>
</li>
<li className="flex items-center">
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
<span>Secure and compliant data storage</span>
</li>
<li className="flex items-center">
<CheckCircle className="h-5 w-5 mr-2 text-white text-opacity-80" />
<span>Simple and intuitive interface</span>
</li>
</ul>
</div>
</div>
</div>

View File

@@ -80,10 +80,22 @@ export default {
height: "0",
},
},
blob: {
"0%, 100%": { transform: "translate(0, 0) scale(1)", borderRadius: "40% 60% 70% 30% / 40% 50% 60% 50%" },
"25%": { transform: "translate(80px, -60px) scale(1.25)", borderRadius: "60% 40% 30% 70% / 60% 30% 70% 40%" },
"50%": { transform: "translate(-50px, 50px) scale(0.85)", borderRadius: "30% 60% 40% 70% / 50% 60% 30% 60%" },
"75%": { transform: "translate(40px, 30px) scale(1.1)", borderRadius: "50% 40% 60% 30% / 40% 70% 50% 60%" },
},
"blob-spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
blob: "blob 10s ease-in-out infinite",
"blob-spin": "blob-spin 20s linear infinite",
},
},
},