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:
@@ -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
|
||||
|
||||
61
apps/Backend/src/routes/greeting.ts
Normal file
61
apps/Backend/src/routes/greeting.ts
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user