Add dev/demo login selector for quick user switching (#62)
* Add dev/demo login selector for quick user switching When AUTH_DISABLED=true, the app now shows a login selector page that lists staff members and clients from the database. Selecting a user sets a localStorage-based session and sends X-Dev-User-Id header on all API requests. A persistent bottom bar shows the active persona with a "Switch user" link. - API: /api/dev/config (public) and /api/dev/users (auth-disabled only) - API: auth middleware reads X-Dev-User-Id header when auth is disabled - Frontend: DevLoginSelector page, DevSessionIndicator bar - Frontend: fetch interceptor injects X-Dev-User-Id on /api/* calls - Tests: 7 passing (5 nav + 2 dev login) Closes #60 Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(e2e): seed dev user in localStorage to prevent login redirect E2E tests were failing because the dev login selector redirects to /login when AUTH_DISABLED=true and no dev user is in localStorage. Added a shared Playwright fixture that pre-seeds localStorage with a default dev user before each test. Also rebased onto latest main to resolve merge conflict in App.test.tsx. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(e2e): mock /api/dev/config to bypass auth redirect in tests The fixture now also mocks /api/dev/config to return authDisabled: false, preventing the app from entering the redirect flow during E2E tests. Previously only seeded localStorage, but the async config fetch from the real Docker API was still triggering the redirect check. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #62.
This commit is contained in:
committed by
GitHub
parent
1cf1f19e1d
commit
3388895912
@@ -13,6 +13,7 @@ import { reportsRouter } from "./routes/reports.js";
|
||||
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
|
||||
import { groomingLogsRouter } from "./routes/groomingLogs.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -33,6 +34,9 @@ app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
// Public booking routes — no auth required, must be registered before auth middleware
|
||||
app.route("/api/book", bookRouter);
|
||||
|
||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
// Protected API routes
|
||||
const api = app.basePath("/api");
|
||||
api.use("*", authMiddleware);
|
||||
|
||||
@@ -40,7 +40,9 @@ if (process.env.AUTH_DISABLED === "true") {
|
||||
|
||||
export const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
c.set("jwtPayload", { sub: "dev-user" } as JwtPayload);
|
||||
const devUserId = c.req.header("X-Dev-User-Id");
|
||||
const sub = devUserId ?? "dev-user";
|
||||
c.set("jwtPayload", { sub } as JwtPayload);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Hono } from "hono";
|
||||
import { getDb, staff, clients, eq, sql } from "@groombook/db";
|
||||
|
||||
const devRouter = new Hono();
|
||||
|
||||
// GET /api/dev/config — tells the frontend whether auth is disabled
|
||||
devRouter.get("/config", (c) => {
|
||||
return c.json({ authDisabled: process.env.AUTH_DISABLED === "true" });
|
||||
});
|
||||
|
||||
// GET /api/dev/users — list staff and clients for the login selector
|
||||
// Only available when AUTH_DISABLED=true
|
||||
devRouter.get("/users", async (c) => {
|
||||
if (process.env.AUTH_DISABLED !== "true") {
|
||||
return c.json({ error: "Not available when auth is enabled" }, 403);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const staffList = await db
|
||||
.select({
|
||||
id: staff.id,
|
||||
name: staff.name,
|
||||
email: staff.email,
|
||||
role: staff.role,
|
||||
})
|
||||
.from(staff)
|
||||
.where(eq(staff.active, true))
|
||||
.orderBy(staff.name);
|
||||
|
||||
const clientList = await db
|
||||
.select({
|
||||
id: clients.id,
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
petCount: sql<number>`(SELECT count(*) FROM pets WHERE pets.client_id = ${clients.id})`.as("pet_count"),
|
||||
})
|
||||
.from(clients)
|
||||
.orderBy(clients.name)
|
||||
.limit(20);
|
||||
|
||||
return c.json({ staff: staffList, clients: clientList });
|
||||
});
|
||||
|
||||
export { devRouter };
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* Booking portal happy-path E2E test.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* Client management E2E tests.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Custom test fixture that bypasses the dev login redirect for E2E tests.
|
||||
*
|
||||
* When AUTH_DISABLED=true, the app fetches /api/dev/config and redirects to
|
||||
* /login if no dev-user is in localStorage. This fixture:
|
||||
* 1. Mocks /api/dev/config to return authDisabled: false
|
||||
* 2. Seeds localStorage with a dev user as a fallback
|
||||
*
|
||||
* This ensures E2E tests render pages directly without the login redirect.
|
||||
*/
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
// Mock the dev config endpoint so the app skips the auth-disabled redirect
|
||||
await page.route("**/api/dev/config", (route) =>
|
||||
route.fulfill({ json: { authDisabled: false } })
|
||||
);
|
||||
// Seed localStorage as a fallback in case the mock is bypassed
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"dev-user",
|
||||
JSON.stringify({ type: "staff", id: "dev-user", name: "Dev User" })
|
||||
);
|
||||
});
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures.js";
|
||||
|
||||
/**
|
||||
* Navigation smoke tests — verifies that each page loads without errors.
|
||||
|
||||
+37
-5
@@ -1,4 +1,5 @@
|
||||
import { Routes, Route, Link, useLocation } from "react-router-dom";
|
||||
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppointmentsPage } from "./pages/Appointments.js";
|
||||
import { ClientsPage } from "./pages/Clients.js";
|
||||
import { ServicesPage } from "./pages/Services.js";
|
||||
@@ -8,6 +9,8 @@ import { BookPage } from "./pages/Book.js";
|
||||
import { ReportsPage } from "./pages/Reports.js";
|
||||
import { GroupBookingPage } from "./pages/GroupBooking.js";
|
||||
import { CustomerPortal } from "./portal/CustomerPortal.js";
|
||||
import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
|
||||
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ to: "/admin", label: "Appointments" },
|
||||
@@ -105,14 +108,43 @@ function AdminLayout() {
|
||||
|
||||
export function App() {
|
||||
const location = useLocation();
|
||||
const [authDisabled, setAuthDisabled] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dev/config")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setAuthDisabled(data.authDisabled === true))
|
||||
.catch(() => setAuthDisabled(false));
|
||||
}, []);
|
||||
|
||||
// Show login selector page
|
||||
if (location.pathname === "/login") {
|
||||
return <DevLoginSelector />;
|
||||
}
|
||||
|
||||
// While checking auth config, render nothing briefly
|
||||
if (authDisabled === null) return null;
|
||||
|
||||
// If auth is disabled and no dev user is selected, redirect to login selector
|
||||
if (authDisabled && !getDevUser() && location.pathname !== "/login") {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith("/admin")) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={<AdminLayout />} />
|
||||
</Routes>
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/admin/*" element={<AdminLayout />} />
|
||||
</Routes>
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <CustomerPortal />;
|
||||
return (
|
||||
<>
|
||||
<CustomerPortal />
|
||||
{authDisabled && <DevSessionIndicator />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,51 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { render, screen, within, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { App } from "../App.js";
|
||||
|
||||
// Prevent fetch errors from page components loading data on mount
|
||||
// Mock fetch to return appropriate responses based on URL
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
} as unknown as Response);
|
||||
localStorage.clear();
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: false }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
} as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
function renderApp(route = "/admin") {
|
||||
async function renderApp(route = "/admin") {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[route]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
return screen.getByRole("navigation");
|
||||
// Wait for the config fetch to resolve
|
||||
const nav = await screen.findByRole("navigation");
|
||||
return nav;
|
||||
}
|
||||
|
||||
describe("App navigation", () => {
|
||||
it("renders the Groom Book brand", () => {
|
||||
const nav = renderApp();
|
||||
it("renders the Groom Book brand", async () => {
|
||||
const nav = await renderApp();
|
||||
expect(
|
||||
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Book CTA button", () => {
|
||||
const nav = renderApp();
|
||||
it("renders the Book CTA button", async () => {
|
||||
const nav = await renderApp();
|
||||
expect(within(nav).getByRole("link", { name: "Book" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all primary nav links", () => {
|
||||
const nav = renderApp();
|
||||
it("renders all primary nav links", async () => {
|
||||
const nav = await renderApp();
|
||||
const expectedLinks = [
|
||||
"Appointments",
|
||||
"Clients",
|
||||
@@ -49,22 +60,84 @@ describe("App navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights the active route link", () => {
|
||||
const nav = renderApp("/admin/clients");
|
||||
it("highlights the active route link", async () => {
|
||||
const nav = await renderApp("/admin/clients");
|
||||
const clientsLink = within(nav).getByText("Clients");
|
||||
// Active links use fontWeight 600
|
||||
expect(clientsLink).toHaveStyle({ fontWeight: "600" });
|
||||
});
|
||||
|
||||
it("renders customer portal at root", () => {
|
||||
it("renders customer portal at root", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
// Customer portal should render at root - no admin nav present
|
||||
expect(
|
||||
screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dev login selector", () => {
|
||||
it("redirects to /login when auth is disabled and no user selected", async () => {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/dev/users") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
staff: [{ id: "s1", name: "Sarah", email: "sarah@test.com", role: "groomer" }],
|
||||
clients: [{ id: "c1", name: "Client A", email: "a@test.com", petCount: 2 }],
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/admin"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should redirect to login selector and show dev login UI
|
||||
await screen.findByText("Dev Login Selector");
|
||||
expect(screen.getByText("Sarah")).toBeInTheDocument();
|
||||
expect(screen.getByText("Client A")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not redirect when a dev user is already selected", async () => {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type: "staff", id: "s1", name: "Sarah" }));
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: true }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/admin"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show admin nav, not login selector
|
||||
const nav = await screen.findByRole("navigation");
|
||||
expect(
|
||||
within(nav).getByText((_, el) => el?.tagName === "STRONG" && /Groom\s*Book/.test(el.textContent ?? ""))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
|
||||
export function DevSessionIndicator() {
|
||||
const user = getDevUser();
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "#1a202c",
|
||||
color: "#e2e8f0",
|
||||
padding: "0.4rem 1rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.75rem",
|
||||
fontSize: 12,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Dev mode: acting as <strong>{user.name}</strong> ({user.type})
|
||||
</span>
|
||||
<Link
|
||||
to="/login"
|
||||
style={{
|
||||
color: "#4f8a6f",
|
||||
textDecoration: "underline",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Switch user
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
|
||||
/**
|
||||
* Patches global fetch to include X-Dev-User-Id header on API requests
|
||||
* when a dev user is selected via the login selector.
|
||||
*
|
||||
* Intentionally mutates window.fetch — this is dev-only (AUTH_DISABLED=true).
|
||||
*/
|
||||
export function installDevFetchInterceptor() {
|
||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
||||
const user = getDevUser();
|
||||
if (!user) return originalFetch(input, init);
|
||||
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
|
||||
// Only inject header for API calls
|
||||
if (!url.startsWith("/api/")) return originalFetch(input, init);
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("X-Dev-User-Id")) {
|
||||
headers.set("X-Dev-User-Id", user.id);
|
||||
}
|
||||
|
||||
return originalFetch(input, { ...init, headers });
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,11 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App.js";
|
||||
import { installDevFetchInterceptor } from "./lib/devFetch.js";
|
||||
import "./index.css";
|
||||
|
||||
installDevFetchInterceptor();
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Root element not found");
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface StaffUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface ClientUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
petCount: number;
|
||||
}
|
||||
|
||||
export function DevLoginSelector() {
|
||||
const navigate = useNavigate();
|
||||
const [staff, setStaff] = useState<StaffUser[]>([]);
|
||||
const [clients, setClients] = useState<ClientUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/dev/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setStaff(data.staff ?? []);
|
||||
setClients(data.clients ?? []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function selectUser(type: "staff" | "client", id: string, name: string) {
|
||||
localStorage.setItem("dev-user", JSON.stringify({ type, id, name }));
|
||||
navigate(type === "staff" ? "/admin" : "/");
|
||||
}
|
||||
|
||||
function skipLogin() {
|
||||
localStorage.removeItem("dev-user");
|
||||
navigate("/admin");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<p style={{ color: "#6b7280" }}>Loading users...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div style={cardStyle}>
|
||||
<div style={{ textAlign: "center", marginBottom: "1.5rem" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22, color: "#1a202c" }}>
|
||||
<span style={{ color: "#4f8a6f" }}>Groom</span>Book
|
||||
</h1>
|
||||
<p style={{ margin: "0.5rem 0 0", color: "#6b7280", fontSize: 14 }}>
|
||||
Dev Login Selector
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 style={sectionStyle}>Staff</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{staff.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => selectUser("staff", s.id, s.name)}
|
||||
style={userButtonStyle}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280" }}>
|
||||
{s.role} · {s.email}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 style={{ ...sectionStyle, marginTop: "1.5rem" }}>Clients</h2>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{clients.map((cl) => (
|
||||
<button
|
||||
key={cl.id}
|
||||
onClick={() => selectUser("client", cl.id, cl.name)}
|
||||
style={userButtonStyle}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{cl.name}</div>
|
||||
<div style={{ fontSize: 12, color: "#6b7280" }}>
|
||||
{cl.petCount} pet{cl.petCount !== 1 ? "s" : ""}
|
||||
{cl.email ? ` \u00b7 ${cl.email}` : ""}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "1.5rem", textAlign: "center" }}>
|
||||
<button onClick={skipLogin} style={skipButtonStyle}>
|
||||
Continue as default dev user
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getDevUser(): { type: string; id: string; name: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem("dev-user");
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDevUser() {
|
||||
localStorage.removeItem("dev-user");
|
||||
}
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
background: "#f0f2f5",
|
||||
padding: "1rem",
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: "#fff",
|
||||
borderRadius: 12,
|
||||
padding: "2rem",
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08)",
|
||||
};
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
margin: "0 0 0.5rem",
|
||||
};
|
||||
|
||||
const userButtonStyle: React.CSSProperties = {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "0.75rem 1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "border-color 0.15s, background 0.15s",
|
||||
};
|
||||
|
||||
const skipButtonStyle: React.CSSProperties = {
|
||||
padding: "0.5rem 1.25rem",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
color: "#6b7280",
|
||||
};
|
||||
Reference in New Issue
Block a user