From b3f7a57623e163c890a877d17c02b4c0b6ecef96 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:03:34 -0300 Subject: [PATCH 01/10] feat: [US-038] - Unsaved changes warning Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 121 +++++++++++++++++- src/app/editor/[projectId]/page.tsx | 40 +----- 2 files changed, 125 insertions(+), 36 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 47f1ccd..3cd6759 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' import ReactFlow, { Background, BackgroundVariant, @@ -416,6 +417,10 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart const [importConfirmDialog, setImportConfirmDialog] = useState<{ pendingData: FlowchartData } | null>(null) + const [showNavigationWarning, setShowNavigationWarning] = useState(false) + + // Track the last saved data to determine dirty state + const lastSavedDataRef = useRef(initialData) // Ref for hidden file input const fileInputRef = useRef(null) @@ -473,6 +478,33 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart } }, [nodes, edges, projectId, draftState.showPrompt]) + // Calculate dirty state by comparing current data with last saved data + const isDirty = useMemo(() => { + const currentData: FlowchartData = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), + } + return !flowchartDataEquals(currentData, lastSavedDataRef.current) + }, [nodes, edges]) + + // Browser beforeunload warning when dirty + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (isDirty) { + event.preventDefault() + // Modern browsers require returnValue to be set + event.returnValue = '' + return '' + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + } + }, [isDirty]) + // Handle restoring draft const handleRestoreDraft = useCallback(() => { if (draftState.savedDraft) { @@ -590,6 +622,9 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart // Clear LocalStorage draft after successful save clearDraft(projectId) + // Update last saved data ref to mark as not dirty + lastSavedDataRef.current = flowchartData + setToast({ message: 'Project saved successfully', type: 'success' }) } catch (error) { console.error('Failed to save project:', error) @@ -932,8 +967,63 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart setConditionEditor(null) }, []) + // Router for navigation + const router = useRouter() + + // Handle back button click - show warning if dirty + const handleBackClick = useCallback(() => { + if (isDirty) { + setShowNavigationWarning(true) + } else { + router.push('/dashboard') + } + }, [isDirty, router]) + + // Confirm navigation (discard unsaved changes) + const handleConfirmNavigation = useCallback(() => { + setShowNavigationWarning(false) + router.push('/dashboard') + }, [router]) + + // Cancel navigation + const handleCancelNavigation = useCallback(() => { + setShowNavigationWarning(false) + }, []) + return ( -
+
+ {/* Editor header with back button and project name */} +
+
+ +

+ {projectName} +

+ {isDirty && ( + + (unsaved changes) + + )} +
+
+ )} + {/* Navigation warning dialog */} + {showNavigationWarning && ( +
+
+

+ Unsaved Changes +

+

+ You have unsaved changes that will be lost if you leave this page. + Are you sure you want to leave? +

+
+ + +
+
+
+ )} + {/* Hidden file input for import */} -
-
- - - - - -

- {project.name} -

-
-
- -
- -
-
+ ) } From f1e92ba1a085dd3830810efc544d6c3d9cd45266 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:04:02 -0300 Subject: [PATCH 02/10] chore: mark US-038 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index 45673e1..d8b6c36 100644 --- a/prd.json +++ b/prd.json @@ -675,7 +675,7 @@ "Verify in browser using dev-browser skill" ], "priority": 38, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index 769e1e2..d14762f 100644 --- a/progress.txt +++ b/progress.txt @@ -552,3 +552,18 @@ - Validate JSON output with JSON.parse before download to ensure validity - Use purple color scheme for Ren'Py-specific button to distinguish from generic export --- + +## 2026-01-22 - US-038 +- What was implemented: Unsaved changes warning with dirty state tracking, beforeunload, and navigation confirmation modal +- Files changed: + - src/app/editor/[projectId]/FlowchartEditor.tsx - added isDirty tracking via useMemo comparing current state to lastSavedDataRef, beforeunload event handler, navigation warning modal, back button with handleBackClick, moved header from page.tsx into this component + - src/app/editor/[projectId]/page.tsx - simplified to only render FlowchartEditor (header moved to client component for dirty state access) +- **Learnings for future iterations:** + - Dirty state tracking uses useMemo comparing JSON.stringify of current flowchart data to a lastSavedDataRef + - lastSavedDataRef is a useRef initialized with initialData and updated after successful save + - Browser beforeunload requires both event.preventDefault() and setting event.returnValue = '' for modern browsers + - Header with back navigation was moved from server component (page.tsx) to client component (FlowchartEditor.tsx) so it can access isDirty state + - Back button uses handleBackClick which checks isDirty before navigating or showing confirmation modal + - Navigation warning modal shows "Leave Page" (red) and "Stay" buttons for clear user action + - "(unsaved changes)" indicator shown next to project name when isDirty is true +--- From b9d778b379c804ffa9ca407fdf0edfe2e9668061 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:06:43 -0300 Subject: [PATCH 03/10] feat: [US-039] - Loading and error states Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 15 ++++- src/app/editor/[projectId]/loading.tsx | 28 +--------- src/app/editor/[projectId]/page.tsx | 56 ++++++++++++++++++- src/components/LoadingSpinner.tsx | 40 +++++++++++++ src/components/Toast.tsx | 19 ++++++- 5 files changed, 127 insertions(+), 31 deletions(-) create mode 100644 src/components/LoadingSpinner.tsx diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 3cd6759..60bb57d 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -413,7 +413,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart const [contextMenu, setContextMenu] = useState(null) const [conditionEditor, setConditionEditor] = useState(null) const [isSaving, setIsSaving] = useState(false) - const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null) + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error'; action?: { label: string; onClick: () => void } } | null>(null) const [importConfirmDialog, setImportConfirmDialog] = useState<{ pendingData: FlowchartData } | null>(null) @@ -425,6 +425,9 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart // Ref for hidden file input const fileInputRef = useRef(null) + // Ref for save function to enable retry without circular dependency + const handleSaveRef = useRef<() => void>(() => {}) + // Check for saved draft on initial render (lazy initialization) const [draftState, setDraftState] = useState<{ showPrompt: boolean @@ -628,12 +631,19 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart setToast({ message: 'Project saved successfully', type: 'success' }) } catch (error) { console.error('Failed to save project:', error) - setToast({ message: 'Failed to save project. Please try again.', type: 'error' }) + setToast({ + message: 'Failed to save project.', + type: 'error', + action: { label: 'Retry', onClick: () => { setToast(null); handleSaveRef.current() } }, + }) } finally { setIsSaving(false) } }, [isSaving, nodes, edges, projectId]) + // Keep ref updated with latest handleSave + handleSaveRef.current = handleSave + const handleExport = useCallback(() => { // Convert React Flow state to FlowchartData const flowchartData: FlowchartData = { @@ -1184,6 +1194,7 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart message={toast.message} type={toast.type} onClose={() => setToast(null)} + action={toast.action} /> )}
diff --git a/src/app/editor/[projectId]/loading.tsx b/src/app/editor/[projectId]/loading.tsx index 9ec9c55..231ffce 100644 --- a/src/app/editor/[projectId]/loading.tsx +++ b/src/app/editor/[projectId]/loading.tsx @@ -1,3 +1,5 @@ +import LoadingSpinner from '@/components/LoadingSpinner' + export default function EditorLoading() { return (
@@ -9,31 +11,7 @@ export default function EditorLoading() {
-
- - - - -

- Loading editor... -

-
+
) diff --git a/src/app/editor/[projectId]/page.tsx b/src/app/editor/[projectId]/page.tsx index 95bb0aa..59ed19d 100644 --- a/src/app/editor/[projectId]/page.tsx +++ b/src/app/editor/[projectId]/page.tsx @@ -1,5 +1,5 @@ import { createClient } from '@/lib/supabase/server' -import { notFound } from 'next/navigation' +import Link from 'next/link' import FlowchartEditor from './FlowchartEditor' import type { FlowchartData } from '@/types/flowchart' @@ -27,7 +27,59 @@ export default async function EditorPage({ params }: PageProps) { .single() if (error || !project) { - notFound() + return ( +
+
+ + + + + +
+
+
+ + + +

+ Project Not Found +

+

+ The project you're looking for doesn't exist or you don't have access to it. +

+ + Back to Dashboard + +
+
+
+ ) } const flowchartData = (project.flowchart_data || { diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..bc2e451 --- /dev/null +++ b/src/components/LoadingSpinner.tsx @@ -0,0 +1,40 @@ +type LoadingSpinnerProps = { + size?: 'sm' | 'md' | 'lg' + message?: string +} + +const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-8 w-8', + lg: 'h-12 w-12', +} + +export default function LoadingSpinner({ size = 'md', message }: LoadingSpinnerProps) { + return ( +
+ + + + + {message && ( +

{message}

+ )} +
+ ) +} diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 18ef18a..52b9687 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -6,16 +6,23 @@ interface ToastProps { message: string type: 'success' | 'error' onClose: () => void + action?: { + label: string + onClick: () => void + } } -export default function Toast({ message, type, onClose }: ToastProps) { +export default function Toast({ message, type, onClose, action }: ToastProps) { useEffect(() => { + // Don't auto-dismiss if there's an action button + if (action) return + const timer = setTimeout(() => { onClose() }, 3000) return () => clearTimeout(timer) - }, [onClose]) + }, [onClose, action]) const bgColor = type === 'success' @@ -60,6 +67,14 @@ export default function Toast({ message, type, onClose }: ToastProps) { > {icon} {message} + {action && ( + + )} + + + ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 6a67346..f9033de 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,130 +1,23 @@ -'use client' - -import { useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Link from 'next/link' -import { createClient } from '@/lib/supabase/client' +import { Suspense } from 'react' +import LoginForm from './LoginForm' export default function LoginPage() { - const router = useRouter() - const searchParams = useSearchParams() - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - // Check for success message from password reset - const message = searchParams.get('message') - const successMessage = message === 'password_reset_success' - ? 'Your password has been reset successfully. Please sign in with your new password.' - : null - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setError(null) - setLoading(true) - - const supabase = createClient() - - const { error } = await supabase.auth.signInWithPassword({ - email, - password, - }) - - if (error) { - setError(error.message) - setLoading(false) - return - } - - router.push('/dashboard') - } - return (
-
-
-

- WebVNWrite -

-

- Sign in to your account -

-
- -
- {successMessage && ( -
-

{successMessage}

-
- )} - - {error && ( -
-

{error}

-
- )} - -
-
- - setEmail(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="you@example.com" - /> -
- -
- - setPassword(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="••••••••" - /> -
+ +

+ WebVNWrite +

+

+ Loading... +

- -
- - Forgot your password? - -
- - -
-
+ } + > + +
) } diff --git a/src/app/signup/SignupForm.tsx b/src/app/signup/SignupForm.tsx new file mode 100644 index 0000000..e839439 --- /dev/null +++ b/src/app/signup/SignupForm.tsx @@ -0,0 +1,236 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' +import { createClient } from '@/lib/supabase/client' + +export default function SignupForm() { + const router = useRouter() + const searchParams = useSearchParams() + // Pre-fill email if provided in URL (from invite link) + const [email, setEmail] = useState(searchParams.get('email') ?? '') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + // Handle invite/signup token from URL hash + // Supabase adds tokens to the URL hash after redirect + const handleTokenFromUrl = async () => { + const hash = window.location.hash + if (hash) { + const params = new URLSearchParams(hash.substring(1)) + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + const type = params.get('type') + + if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) { + const supabase = createClient() + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }) + + if (error) { + setError('Invalid or expired invite link. Please request a new invitation.') + return + } + + // Get the user's email from the session + const { data: { user } } = await supabase.auth.getUser() + if (user?.email) { + setEmail(user.email) + } + } + } + } + + handleTokenFromUrl() + }, [searchParams]) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + // Validate passwords match + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + + // Validate password length + if (password.length < 6) { + setError('Password must be at least 6 characters') + return + } + + setLoading(true) + + const supabase = createClient() + + // Check if user already has a session (from invite link) + const { data: { session } } = await supabase.auth.getSession() + + if (session) { + // User was invited and has a session - update their password + const { error: updateError } = await supabase.auth.updateUser({ + password, + }) + + if (updateError) { + setError(updateError.message) + setLoading(false) + return + } + + // Create profile record + const { error: profileError } = await supabase.from('profiles').upsert({ + id: session.user.id, + email: session.user.email, + display_name: session.user.email?.split('@')[0] || 'User', + is_admin: false, + }) + + if (profileError) { + setError(profileError.message) + setLoading(false) + return + } + + router.push('/dashboard') + } else { + // Regular signup flow (if allowed) + const { data, error } = await supabase.auth.signUp({ + email, + password, + }) + + if (error) { + setError(error.message) + setLoading(false) + return + } + + if (data.user) { + // Create profile record + const { error: profileError } = await supabase.from('profiles').upsert({ + id: data.user.id, + email: data.user.email, + display_name: email.split('@')[0] || 'User', + is_admin: false, + }) + + if (profileError) { + setError(profileError.message) + setLoading(false) + return + } + + router.push('/dashboard') + } + } + } + + return ( +
+
+

+ WebVNWrite +

+

+ Complete your account setup +

+
+ +
+ {error && ( +
+

{error}

+
+ )} + +
+
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+
+ + + +

+ Already have an account?{' '} + + Sign in + +

+
+
+ ) +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index fb1a733..de79472 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,238 +1,23 @@ -'use client' - -import { useState, useEffect } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Link from 'next/link' -import { createClient } from '@/lib/supabase/client' +import { Suspense } from 'react' +import SignupForm from './SignupForm' export default function SignupPage() { - const router = useRouter() - const searchParams = useSearchParams() - // Pre-fill email if provided in URL (from invite link) - const [email, setEmail] = useState(searchParams.get('email') ?? '') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - useEffect(() => { - // Handle invite/signup token from URL hash - // Supabase adds tokens to the URL hash after redirect - const handleTokenFromUrl = async () => { - const hash = window.location.hash - if (hash) { - const params = new URLSearchParams(hash.substring(1)) - const accessToken = params.get('access_token') - const refreshToken = params.get('refresh_token') - const type = params.get('type') - - if (accessToken && refreshToken && (type === 'invite' || type === 'signup')) { - const supabase = createClient() - const { error } = await supabase.auth.setSession({ - access_token: accessToken, - refresh_token: refreshToken, - }) - - if (error) { - setError('Invalid or expired invite link. Please request a new invitation.') - return - } - - // Get the user's email from the session - const { data: { user } } = await supabase.auth.getUser() - if (user?.email) { - setEmail(user.email) - } - } - } - } - - handleTokenFromUrl() - }, [searchParams]) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setError(null) - - // Validate passwords match - if (password !== confirmPassword) { - setError('Passwords do not match') - return - } - - // Validate password length - if (password.length < 6) { - setError('Password must be at least 6 characters') - return - } - - setLoading(true) - - const supabase = createClient() - - // Check if user already has a session (from invite link) - const { data: { session } } = await supabase.auth.getSession() - - if (session) { - // User was invited and has a session - update their password - const { error: updateError } = await supabase.auth.updateUser({ - password, - }) - - if (updateError) { - setError(updateError.message) - setLoading(false) - return - } - - // Create profile record - const { error: profileError } = await supabase.from('profiles').upsert({ - id: session.user.id, - email: session.user.email, - display_name: session.user.email?.split('@')[0] || 'User', - is_admin: false, - }) - - if (profileError) { - setError(profileError.message) - setLoading(false) - return - } - - router.push('/dashboard') - } else { - // Regular signup flow (if allowed) - const { data, error } = await supabase.auth.signUp({ - email, - password, - }) - - if (error) { - setError(error.message) - setLoading(false) - return - } - - if (data.user) { - // Create profile record - const { error: profileError } = await supabase.from('profiles').upsert({ - id: data.user.id, - email: data.user.email, - display_name: email.split('@')[0] || 'User', - is_admin: false, - }) - - if (profileError) { - setError(profileError.message) - setLoading(false) - return - } - - router.push('/dashboard') - } - } - } - return (
-
-
-

- WebVNWrite -

-

- Complete your account setup -

-
- -
- {error && ( -
-

{error}

-
- )} - -
-
- - setEmail(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="you@example.com" - /> -
- -
- - setPassword(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="••••••••" - /> -
- -
- - setConfirmPassword(e.target.value)} - className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" - placeholder="••••••••" - /> -
+ +

+ WebVNWrite +

+

+ Loading... +

- - - -

- Already have an account?{' '} - - Sign in - -

-
-
+ } + > + +
) } From dd8fcb79cf7bd54d706502fe76df2552e6caefc1 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:31:48 -0300 Subject: [PATCH 06/10] fix: redirect root page to dashboard instead of default Next.js template Co-Authored-By: Claude Opus 4.5 --- src/app/page.tsx | 64 ++---------------------------------------------- 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..28c5ca1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from 'next/navigation' export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect('/dashboard') } From ff52df2c28e643bc9c9463e278ca0146f2c5ad87 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:47:39 -0300 Subject: [PATCH 07/10] feat: [US-040] - Conditionals on choice options Co-Authored-By: Claude Opus 4.5 --- prd.json | 53 ++++++ progress.txt | 17 ++ .../editor/[projectId]/FlowchartEditor.tsx | 5 + .../editor/OptionConditionEditor.tsx | 171 ++++++++++++++++++ src/components/editor/nodes/ChoiceNode.tsx | 149 ++++++++++++--- src/types/flowchart.ts | 15 +- 6 files changed, 376 insertions(+), 34 deletions(-) create mode 100644 src/components/editor/OptionConditionEditor.tsx diff --git a/prd.json b/prd.json index fa18f0b..dca59fa 100644 --- a/prd.json +++ b/prd.json @@ -694,6 +694,59 @@ "priority": 39, "passes": true, "notes": "" + }, + { + "id": "US-040", + "title": "Conditionals on choice options", + "description": "As a user, I want individual choice options to have variable conditions so that options are only visible when certain conditions are met (e.g., affection > 10).", + "acceptanceCriteria": [ + "Each ChoiceOption can have optional condition (variableName, operator, value)", + "Update ChoiceNode UI to show 'Add condition' button per option", + "Condition editor modal for each option", + "Visual indicator (icon/badge) on options with conditions", + "Update TypeScript types: ChoiceOption gets optional condition field", + "Export includes per-option conditions in Ren'Py JSON", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 40, + "passes": true, + "notes": "Dependencies: US-018, US-019, US-025. Complexity: M" + }, + { + "id": "US-041", + "title": "Change password for logged-in user", + "description": "As a user, I want to change my own password from a settings/profile page so that I can keep my account secure.", + "acceptanceCriteria": [ + "Settings/profile page accessible from dashboard header", + "Form with: current password, new password, confirm new password fields", + "Calls Supabase updateUser with new password", + "Requires current password verification (re-authenticate)", + "Shows success/error messages", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 41, + "passes": false, + "notes": "Dependencies: US-004. Complexity: S" + }, + { + "id": "US-042", + "title": "Password reset modal on token arrival", + "description": "As a user, I want a modal to automatically appear when a password reset token is detected so that I can set my new password seamlessly.", + "acceptanceCriteria": [ + "Detect password reset token in URL (from Supabase email link)", + "Show modal/dialog automatically when token present", + "Modal has: new password, confirm password fields", + "Calls Supabase updateUser with token to complete reset", + "On success, close modal and redirect to login", + "On error, show error message", + "Typecheck passes", + "Verify in browser using dev-browser skill" + ], + "priority": 42, + "passes": false, + "notes": "Dependencies: US-006. Complexity: S" } ] } diff --git a/progress.txt b/progress.txt index a62938e..280f732 100644 --- a/progress.txt +++ b/progress.txt @@ -586,3 +586,20 @@ - LoadingSpinner uses size prop ('sm' | 'md' | 'lg') for flexibility across different contexts - Link component from next/link is needed in server components for navigation (no useRouter in server components) --- + +## 2026-01-22 - US-040 +- What was implemented: Conditionals on choice options - per-option visibility conditions +- Files changed: + - src/types/flowchart.ts - moved Condition type before ChoiceOption, added optional condition field to ChoiceOption + - src/components/editor/nodes/ChoiceNode.tsx - added condition button per option, condition badge display, condition editing state management + - src/components/editor/OptionConditionEditor.tsx - new modal component for editing per-option conditions (variable name, operator, value) + - src/app/editor/[projectId]/FlowchartEditor.tsx - updated Ren'Py export to include per-option conditions (option condition takes priority over edge condition) +- **Learnings for future iterations:** + - Per-option conditions use the same Condition type as edge conditions + - Condition type needed to be moved above ChoiceOption in types file since ChoiceOption now references it + - Use `delete obj.property` pattern instead of destructuring with unused variable to avoid lint warnings + - OptionConditionEditor is separate from ConditionEditor because it operates on option IDs vs edge IDs + - In Ren'Py export, option-level condition takes priority over edge condition since it represents visibility + - Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not + - Condition badge below option shows "if variableName operator value" text in compact format +--- diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 60bb57d..707cebc 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -297,6 +297,11 @@ function convertToRenpyFormat( } } + // Per-option condition (visibility condition) takes priority over edge condition + if (option.condition) { + choice.condition = option.condition + } + return choice }) diff --git a/src/components/editor/OptionConditionEditor.tsx b/src/components/editor/OptionConditionEditor.tsx new file mode 100644 index 0000000..23739c5 --- /dev/null +++ b/src/components/editor/OptionConditionEditor.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import type { Condition } from '@/types/flowchart' + +type OptionConditionEditorProps = { + optionId: string + optionLabel: string + condition?: Condition + onSave: (optionId: string, condition: Condition) => void + onRemove: (optionId: string) => void + onCancel: () => void +} + +const OPERATORS: Condition['operator'][] = ['>', '<', '==', '>=', '<=', '!='] + +export default function OptionConditionEditor({ + optionId, + optionLabel, + condition, + onSave, + onRemove, + onCancel, +}: OptionConditionEditorProps) { + const [variableName, setVariableName] = useState(condition?.variableName ?? '') + const [operator, setOperator] = useState(condition?.operator ?? '==') + const [value, setValue] = useState(condition?.value ?? 0) + + // Close on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onCancel]) + + const handleSave = useCallback(() => { + if (!variableName.trim()) return + onSave(optionId, { + variableName: variableName.trim(), + operator, + value, + }) + }, [optionId, variableName, operator, value, onSave]) + + const handleRemove = useCallback(() => { + onRemove(optionId) + }, [optionId, onRemove]) + + const hasExistingCondition = !!condition + + return ( +
+
e.stopPropagation()} + > +

+ {hasExistingCondition ? 'Edit Option Condition' : 'Add Option Condition'} +

+

+ Option: {optionLabel || '(unnamed)'} +

+ +
+ {/* Variable Name Input */} +
+ + setVariableName(e.target.value)} + placeholder="e.g., affection, score, health" + autoFocus + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ + {/* Operator Dropdown */} +
+ + +
+ + {/* Value Number Input */} +
+ + setValue(parseFloat(e.target.value) || 0)} + className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-100" + /> +
+ + {/* Preview */} + {variableName.trim() && ( +
+ + Show option when: {variableName.trim()} {operator} {value} + +
+ )} +
+ + {/* Action Buttons */} +
+
+ {hasExistingCondition && ( + + )} +
+
+ + +
+
+
+
+ ) +} diff --git a/src/components/editor/nodes/ChoiceNode.tsx b/src/components/editor/nodes/ChoiceNode.tsx index 7073d2d..82bca9b 100644 --- a/src/components/editor/nodes/ChoiceNode.tsx +++ b/src/components/editor/nodes/ChoiceNode.tsx @@ -1,12 +1,15 @@ 'use client' -import { useCallback, ChangeEvent } from 'react' +import { useCallback, useState, ChangeEvent } from 'react' import { Handle, Position, NodeProps, useReactFlow } from 'reactflow' import { nanoid } from 'nanoid' +import type { Condition } from '@/types/flowchart' +import OptionConditionEditor from '@/components/editor/OptionConditionEditor' type ChoiceOption = { id: string label: string + condition?: Condition } type ChoiceNodeData = { @@ -19,6 +22,7 @@ const MAX_OPTIONS = 6 export default function ChoiceNode({ id, data }: NodeProps) { const { setNodes } = useReactFlow() + const [editingConditionOptionId, setEditingConditionOptionId] = useState(null) const updatePrompt = useCallback( (e: ChangeEvent) => { @@ -96,6 +100,57 @@ export default function ChoiceNode({ id, data }: NodeProps) { [id, data.options.length, setNodes] ) + const handleSaveCondition = useCallback( + (optionId: string, condition: Condition) => { + setNodes((nodes) => + nodes.map((node) => + node.id === id + ? { + ...node, + data: { + ...node.data, + options: node.data.options.map((opt: ChoiceOption) => + opt.id === optionId ? { ...opt, condition } : opt + ), + }, + } + : node + ) + ) + setEditingConditionOptionId(null) + }, + [id, setNodes] + ) + + const handleRemoveCondition = useCallback( + (optionId: string) => { + setNodes((nodes) => + nodes.map((node) => + node.id === id + ? { + ...node, + data: { + ...node.data, + options: node.data.options.map((opt: ChoiceOption) => { + if (opt.id !== optionId) return opt + const updated = { ...opt } + delete updated.condition + return updated + }), + }, + } + : node + ) + ) + setEditingConditionOptionId(null) + }, + [id, setNodes] + ) + + const editingOption = editingConditionOptionId + ? data.options.find((opt) => opt.id === editingConditionOptionId) + : null + return (
) {
{data.options.map((option, index) => ( -
- updateOptionLabel(option.id, e.target.value)} - placeholder={`Option ${index + 1}`} - className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" - /> - - +
+
+ updateOptionLabel(option.id, e.target.value)} + placeholder={`Option ${index + 1}`} + className="flex-1 rounded border border-green-300 bg-white px-2 py-1 text-sm focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-green-600 dark:bg-zinc-800 dark:text-white dark:placeholder-zinc-400" + /> + + + +
+ {option.condition && ( +
+ + if {option.condition.variableName} {option.condition.operator} {option.condition.value} + +
+ )}
))}
@@ -158,6 +242,17 @@ export default function ChoiceNode({ id, data }: NodeProps) { > + Add Option + + {editingOption && ( + setEditingConditionOptionId(null)} + /> + )}
) } diff --git a/src/types/flowchart.ts b/src/types/flowchart.ts index 21a5c54..50fee50 100644 --- a/src/types/flowchart.ts +++ b/src/types/flowchart.ts @@ -4,6 +4,13 @@ export type Position = { y: number; }; +// Condition type for conditional edges and choice options +export type Condition = { + variableName: string; + operator: '>' | '<' | '==' | '>=' | '<=' | '!='; + value: number; +}; + // DialogueNode type: represents character speech/dialogue export type DialogueNode = { id: string; @@ -19,6 +26,7 @@ export type DialogueNode = { export type ChoiceOption = { id: string; label: string; + condition?: Condition; }; // ChoiceNode type: represents branching decisions @@ -47,13 +55,6 @@ export type VariableNode = { // Union type for all node types export type FlowchartNode = DialogueNode | ChoiceNode | VariableNode; -// Condition type for conditional edges -export type Condition = { - variableName: string; - operator: '>' | '<' | '==' | '>=' | '<=' | '!='; - value: number; -}; - // FlowchartEdge type: represents connections between nodes export type FlowchartEdge = { id: string; From e8a6942cfe3cfd150c344901b21850f38dfe6612 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:49:25 -0300 Subject: [PATCH 08/10] feat: [US-041] - Change password for logged-in user Co-Authored-By: Claude Opus 4.5 --- src/app/dashboard/settings/page.tsx | 161 ++++++++++++++++++++++++++++ src/components/Navbar.tsx | 6 ++ 2 files changed, 167 insertions(+) create mode 100644 src/app/dashboard/settings/page.tsx diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..23898f1 --- /dev/null +++ b/src/app/dashboard/settings/page.tsx @@ -0,0 +1,161 @@ +'use client' + +import { useState } from 'react' +import { createClient } from '@/lib/supabase/client' + +export default function SettingsPage() { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [isLoading, setIsLoading] = useState(false) + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setSuccess('') + + if (newPassword !== confirmPassword) { + setError('New passwords do not match.') + return + } + + if (newPassword.length < 6) { + setError('New password must be at least 6 characters.') + return + } + + setIsLoading(true) + + try { + const supabase = createClient() + + // Re-authenticate with current password + const { data: { user } } = await supabase.auth.getUser() + if (!user?.email) { + setError('Unable to verify current user.') + setIsLoading(false) + return + } + + const { error: signInError } = await supabase.auth.signInWithPassword({ + email: user.email, + password: currentPassword, + }) + + if (signInError) { + setError('Current password is incorrect.') + setIsLoading(false) + return + } + + // Update to new password + const { error: updateError } = await supabase.auth.updateUser({ + password: newPassword, + }) + + if (updateError) { + setError(updateError.message) + setIsLoading(false) + return + } + + setSuccess('Password updated successfully.') + setCurrentPassword('') + setNewPassword('') + setConfirmPassword('') + } catch { + setError('An unexpected error occurred.') + } finally { + setIsLoading(false) + } + } + + return ( +
+

+ Settings +

+ +
+

+ Change Password +

+ +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+ + setCurrentPassword(e.target.value)} + required + className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ +
+ + setNewPassword(e.target.value)} + required + className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + className="w-full rounded-md border border-zinc-300 px-3 py-2 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder-zinc-500" + /> +
+ + +
+
+
+ ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index d7d1a57..1e7f032 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -29,6 +29,12 @@ export default function Navbar({ userEmail, isAdmin }: NavbarProps) { Invite User )} + + Settings + {userEmail} From d47794ebc9de82babd83a8ec5a2ae0d8979b70d2 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:49:54 -0300 Subject: [PATCH 09/10] chore: mark US-041 as complete and update progress log Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/prd.json b/prd.json index dca59fa..4aa7581 100644 --- a/prd.json +++ b/prd.json @@ -727,7 +727,7 @@ "Verify in browser using dev-browser skill" ], "priority": 41, - "passes": false, + "passes": true, "notes": "Dependencies: US-004. Complexity: S" }, { diff --git a/progress.txt b/progress.txt index 280f732..b4d0d8b 100644 --- a/progress.txt +++ b/progress.txt @@ -26,6 +26,7 @@ - Use nanoid for generating unique node IDs (import from 'nanoid') - Reusable LoadingSpinner component in `src/components/LoadingSpinner.tsx` with size ('sm'|'md'|'lg') and optional message - Toast component supports an optional `action` prop: `{ label: string; onClick: () => void }` for retry/undo buttons +- Settings page at `/dashboard/settings` reuses dashboard layout; re-auth via signInWithPassword before updateUser --- @@ -603,3 +604,17 @@ - Condition button uses amber color scheme (bg-amber-100) when condition is set, neutral when not - Condition badge below option shows "if variableName operator value" text in compact format --- + +## 2026-01-22 - US-041 +- What was implemented: Change password for logged-in user from settings page +- Files changed: + - src/app/dashboard/settings/page.tsx - new client component with password change form (current, new, confirm fields) + - src/components/Navbar.tsx - added "Settings" link to navbar +- **Learnings for future iterations:** + - Settings page lives under /dashboard/settings to reuse the dashboard layout (navbar, auth check) + - Re-authentication uses signInWithPassword with current password before allowing updateUser + - Supabase getUser() returns current user email needed for re-auth signInWithPassword call + - Password validation: check match and minimum length (6 chars) before making API calls + - Clear form fields after successful password update for security + - Settings link in navbar uses neutral zinc colors to distinguish from admin/action links +--- From 4fbc5649443e38d2174aaec21cdd8ed4ff6e90f4 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:51:52 -0300 Subject: [PATCH 10/10] feat: [US-042] - Password reset modal on token arrival Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 14 +++ src/app/login/page.tsx | 2 + src/components/PasswordResetModal.tsx | 170 ++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/components/PasswordResetModal.tsx diff --git a/prd.json b/prd.json index 4aa7581..196ae27 100644 --- a/prd.json +++ b/prd.json @@ -745,7 +745,7 @@ "Verify in browser using dev-browser skill" ], "priority": 42, - "passes": false, + "passes": true, "notes": "Dependencies: US-006. Complexity: S" } ] diff --git a/progress.txt b/progress.txt index b4d0d8b..c5d0f32 100644 --- a/progress.txt +++ b/progress.txt @@ -618,3 +618,17 @@ - Clear form fields after successful password update for security - Settings link in navbar uses neutral zinc colors to distinguish from admin/action links --- + +## 2026-01-22 - US-042 +- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL +- Files changed: + - src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form + - src/app/login/page.tsx - integrated PasswordResetModal component on the login page +- **Learnings for future iterations:** + - PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens + - Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh) + - Separate tokenError state from form error state to show different UI (expired link vs. form validation) + - Modal uses fixed positioning with z-50 to overlay above page content + - After successful password update, sign out the user and redirect to login with success message (same as reset-password page) + - The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns +--- diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index f9033de..231b62a 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import LoginForm from './LoginForm' +import PasswordResetModal from '@/components/PasswordResetModal' export default function LoginPage() { return ( @@ -18,6 +19,7 @@ export default function LoginPage() { > +
) } diff --git a/src/components/PasswordResetModal.tsx b/src/components/PasswordResetModal.tsx new file mode 100644 index 0000000..9a190f7 --- /dev/null +++ b/src/components/PasswordResetModal.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' + +export default function PasswordResetModal() { + const router = useRouter() + const [isOpen, setIsOpen] = useState(false) + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [tokenError, setTokenError] = useState(null) + + useEffect(() => { + const handleTokenFromUrl = async () => { + const hash = window.location.hash + if (!hash) return + + const params = new URLSearchParams(hash.substring(1)) + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + const type = params.get('type') + + if (accessToken && refreshToken && type === 'recovery') { + const supabase = createClient() + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }) + + if (error) { + setTokenError('Invalid or expired reset link. Please request a new password reset.') + return + } + + // Clear hash from URL without reloading + window.history.replaceState(null, '', window.location.pathname + window.location.search) + setIsOpen(true) + } + } + + handleTokenFromUrl() + }, []) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + + if (password.length < 6) { + setError('Password must be at least 6 characters') + return + } + + setLoading(true) + + const supabase = createClient() + + const { error: updateError } = await supabase.auth.updateUser({ + password, + }) + + if (updateError) { + setError(updateError.message) + setLoading(false) + return + } + + await supabase.auth.signOut() + + setIsOpen(false) + router.push('/login?message=password_reset_success') + } + + if (tokenError) { + return ( +
+
+

+ Reset link expired +

+

+ {tokenError} +

+ +
+
+ ) + } + + if (!isOpen) return null + + return ( +
+
+

+ Set new password +

+

+ Enter your new password below. +

+ +
+ {error && ( +
+

{error}

+
+ )} + +
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+ + +
+
+
+ ) +}