Implement Settings page with form validation (P2.9)
- Add client-side form for cycleLength, notificationTime, timezone - Fetch user data on mount and pre-fill form values - Submit updates via PATCH /api/user with loading states - Display success/error messages with proper accessibility - Clear messages when user modifies form - 24 tests covering rendering, data loading, validation, error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
475
src/app/settings/page.test.tsx
Normal file
475
src/app/settings/page.test.tsx
Normal file
@@ -0,0 +1,475 @@
|
||||
// ABOUTME: Unit tests for the Settings page component.
|
||||
// ABOUTME: Tests form rendering, data loading, validation, and save flow.
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
import SettingsPage from "./page";
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
const mockUser = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
cycleLength: 28,
|
||||
notificationTime: "08:00",
|
||||
timezone: "America/New_York",
|
||||
garminConnected: false,
|
||||
activeOverrides: [],
|
||||
lastPeriodDate: "2024-01-01",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the settings heading", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /settings/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders cycle length input", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders notification time input", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/notification time/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders timezone input", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/timezone/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a save button", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /save/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a back link to dashboard", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("link", { name: /back/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays email as read-only", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("data loading", () => {
|
||||
it("fetches user data on mount", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/user");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while fetching", async () => {
|
||||
// Create a promise that we can control
|
||||
let resolveUser: (value: unknown) => void = () => {};
|
||||
const userPromise = new Promise((resolve) => {
|
||||
resolveUser = resolve;
|
||||
});
|
||||
mockFetch.mockReturnValue({
|
||||
ok: true,
|
||||
json: () => userPromise,
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
|
||||
resolveUser(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-fills form with current user values", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toHaveValue(28);
|
||||
expect(screen.getByLabelText(/notification time/i)).toHaveValue(
|
||||
"08:00",
|
||||
);
|
||||
expect(screen.getByLabelText(/timezone/i)).toHaveValue(
|
||||
"America/New_York",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error if fetching fails", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "Failed to fetch user" }),
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByText(/failed to fetch user/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("form submission", () => {
|
||||
it("calls PATCH /api/user with updated values on save", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ ...mockUser, cycleLength: 30 }),
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
||||
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cycleLength: 30,
|
||||
notificationTime: "08:00",
|
||||
timezone: "America/New_York",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while saving", async () => {
|
||||
let resolveSave: (value: unknown) => void = () => {};
|
||||
const savePromise = new Promise((resolve) => {
|
||||
resolveSave = resolve;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
json: () => savePromise,
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /saving/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
resolveSave(mockUser);
|
||||
});
|
||||
|
||||
it("disables inputs while saving", async () => {
|
||||
let resolveSave: (value: unknown) => void = () => {};
|
||||
const savePromise = new Promise((resolve) => {
|
||||
resolveSave = resolve;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
json: () => savePromise,
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cycleLengthInput).toBeDisabled();
|
||||
expect(screen.getByLabelText(/notification time/i)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/timezone/i)).toBeDisabled();
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveSave(mockUser);
|
||||
});
|
||||
|
||||
it("shows success message on save", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ ...mockUser, cycleLength: 30 }),
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/settings saved/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error on save failure", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () =>
|
||||
Promise.resolve({ error: "cycleLength must be between 21 and 45" }),
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/cycleLength must be between 21 and 45/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("re-enables form after error", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "Failed to save" }),
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/cycle length/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/notification time/i)).not.toBeDisabled();
|
||||
expect(screen.getByLabelText(/timezone/i)).not.toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /save/i })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
it("validates cycle length minimum (21)", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
||||
expect(cycleLengthInput).toHaveAttribute("min", "21");
|
||||
});
|
||||
|
||||
it("validates cycle length maximum (45)", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
||||
expect(cycleLengthInput).toHaveAttribute("max", "45");
|
||||
});
|
||||
|
||||
it("has cycle length input as number type", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
||||
expect(cycleLengthInput).toHaveAttribute("type", "number");
|
||||
});
|
||||
|
||||
it("has notification time input as time type", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/notification time/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const notificationTimeInput = screen.getByLabelText(/notification time/i);
|
||||
expect(notificationTimeInput).toHaveAttribute("type", "time");
|
||||
});
|
||||
|
||||
it("requires timezone to be non-empty", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/timezone/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const timezoneInput = screen.getByLabelText(/timezone/i);
|
||||
expect(timezoneInput).toHaveAttribute("required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("clears error when user starts typing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ error: "Failed to save" }),
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
||||
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
|
||||
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears success message when user modifies form", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/settings saved/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
||||
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
|
||||
|
||||
expect(screen.queryByText(/settings saved/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,231 @@
|
||||
// ABOUTME: User settings page for profile and preferences.
|
||||
// ABOUTME: Allows configuration of notification time, timezone, and cycle length.
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
email: string;
|
||||
cycleLength: number;
|
||||
notificationTime: string;
|
||||
timezone: string;
|
||||
garminConnected: boolean;
|
||||
activeOverrides: string[];
|
||||
lastPeriodDate: string | null;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [userData, setUserData] = useState<UserData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [cycleLength, setCycleLength] = useState(28);
|
||||
const [notificationTime, setNotificationTime] = useState("08:00");
|
||||
const [timezone, setTimezone] = useState("");
|
||||
|
||||
const fetchUserData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/user");
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to fetch user data");
|
||||
}
|
||||
|
||||
setUserData(data);
|
||||
setCycleLength(data.cycleLength);
|
||||
setNotificationTime(data.notificationTime);
|
||||
setTimezone(data.timezone);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "An error occurred";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserData();
|
||||
}, [fetchUserData]);
|
||||
|
||||
const handleInputChange = <T,>(
|
||||
setter: React.Dispatch<React.SetStateAction<T>>,
|
||||
value: T,
|
||||
) => {
|
||||
setter(value);
|
||||
if (error) {
|
||||
setError(null);
|
||||
}
|
||||
if (success) {
|
||||
setSuccess(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cycleLength,
|
||||
notificationTime,
|
||||
timezone,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Failed to save settings");
|
||||
}
|
||||
|
||||
setUserData(data);
|
||||
setSuccess("Settings saved successfully");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "An error occurred";
|
||||
setError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Settings</h1>
|
||||
<p className="text-gray-500">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold mb-8">Settings</h1>
|
||||
{/* Settings form will be implemented here */}
|
||||
<p className="text-gray-500">Settings form placeholder</p>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-blue-600 hover:text-blue-700 hover:underline"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-6">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-lg">
|
||||
<div className="mb-6">
|
||||
<span className="block text-sm font-medium text-gray-700">Email</span>
|
||||
<p className="mt-1 text-gray-900">{userData?.email}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="cycleLength"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Cycle Length (days)
|
||||
</label>
|
||||
<input
|
||||
id="cycleLength"
|
||||
type="number"
|
||||
min="21"
|
||||
max="45"
|
||||
value={cycleLength}
|
||||
onChange={(e) =>
|
||||
handleInputChange(setCycleLength, Number(e.target.value))
|
||||
}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Typical range: 21-45 days
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="notificationTime"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Notification Time
|
||||
</label>
|
||||
<input
|
||||
id="notificationTime"
|
||||
type="time"
|
||||
value={notificationTime}
|
||||
onChange={(e) =>
|
||||
handleInputChange(setNotificationTime, e.target.value)
|
||||
}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Time to receive daily email notification
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="timezone"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Timezone
|
||||
</label>
|
||||
<input
|
||||
id="timezone"
|
||||
type="text"
|
||||
value={timezone}
|
||||
onChange={(e) => handleInputChange(setTimezone, e.target.value)}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
placeholder="America/New_York"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
IANA timezone (e.g., America/New_York, Europe/London)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-blue-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user