diff options
author | Pawel Zelawski <pawel.zelawski@outlook.com> | 2025-04-10 10:55:22 +0200 |
---|---|---|
committer | Pawel Zelawski <pawel.zelawski@outlook.com> | 2025-04-10 10:55:22 +0200 |
commit | c852aa92f84d0c18b1bd7361163498a542461d45 (patch) | |
tree | feb1b34c35c4e7a2d35c70dba35b3367c7f638b0 | |
parent | 326caf949ec8622c04b0e3352c5eac5370f161e4 (diff) |
feat(client): implement react frontend application
- Created main entry point `src/client/main.tsx`.
- Created main `App` component `src/client/App.tsx`.
- Implemented state management (`initial`, `waiting`, `success`, `failed`) using `useState`.
- Implemented `handleStart` to call backend `/api/digiid/start` and transition to `waiting` state.
- Implemented `useEffect` hook for polling backend `/api/digiid/status/:sessionId` in `waiting` state.
- Updates UI state based on polling response (`success`/`failed`).
- Handles polling errors and cleanup.
- Implemented `handleReset` to return to `initial` state.
- Implemented views for each state:
- `initial`: Welcome message, Start button.
- `waiting`: QR code display, status message, Cancel button.
- `success`: Success message, verified address, address type, Start Over button.
- `failed`: Failure message, error details, Try Again button.
- Added client-side address type helper `getDigiByteAddressType` in `src/client/utils.ts`.
- Added basic CSS styling in `src/client/index.css` with light/dark mode support.
- Added frontend-related scripts (`dev:frontend`, `dev`, `build`, `lint`, `preview`) to `package.json`.
- Fixed linter type issue with `setInterval` return value.
-rw-r--r-- | package.json | 7 | ||||
-rw-r--r-- | src/client/App.tsx | 193 | ||||
-rw-r--r-- | src/client/index.css | 115 | ||||
-rw-r--r-- | src/client/main.tsx | 10 | ||||
-rw-r--r-- | src/client/utils.ts | 20 |
5 files changed, 344 insertions, 1 deletions
diff --git a/package.json b/package.json index 9224785..113d16c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev:backend": "nodemon --watch src/server --ext ts --exec \"node --loader ts-node/esm src/server/main.ts\"" + "dev:frontend": "vite", + "dev:backend": "nodemon --watch src/server --ext ts --exec \"node --loader ts-node/esm src/server/main.ts\"", + "dev": "npm-run-all --parallel dev:frontend dev:backend", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" }, "repository": { "type": "git", diff --git a/src/client/App.tsx b/src/client/App.tsx new file mode 100644 index 0000000..00280cd --- /dev/null +++ b/src/client/App.tsx @@ -0,0 +1,193 @@ +import React, { useState, useEffect } from 'react'; +import { getDigiByteAddressType } from './utils'; // Import the address type helper + +// Define the possible UI states +type UiState = 'initial' | 'waiting' | 'success' | 'failed'; + +// Define the structure for result data (success or failure) +interface ResultData { + address?: string; // Present on success + error?: string; // Present on failure + addressType?: string; // Added later +} + +function App() { + const [uiState, setUiState] = useState<UiState>('initial'); + const [sessionId, setSessionId] = useState<string | null>(null); + const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string | null>(null); + const [resultData, setResultData] = useState<ResultData | null>(null); + const [isLoading, setIsLoading] = useState<boolean>(false); + const [error, setError] = useState<string | null>(null); // For general fetch errors + + // Polling interval for status check (in milliseconds) + const POLLING_INTERVAL = 2000; // Check every 2 seconds + + // Effect for polling the status endpoint when in 'waiting' state + useEffect(() => { + // Only poll if we are in the waiting state and have a session ID + if (uiState !== 'waiting' || !sessionId) { + return; // Exit if not applicable + } + + console.log(`Starting status polling for session: ${sessionId}`); + let intervalId: any = null; // Use 'any' to avoid browser/node type conflict for setInterval return type + + const checkStatus = async () => { + if (!sessionId) return; // Should not happen here, but type guard + + console.log(`Checking status for session: ${sessionId}`); + try { + const response = await fetch(`/api/digiid/status/${sessionId}`); + if (!response.ok) { + // Handle specific errors like 404 (session not found/expired) + if (response.status === 404) { + console.warn(`Session ${sessionId} not found or expired during polling.`); + setError('Session expired or could not be found.'); + setUiState('failed'); // Transition to failed state + setResultData({ error: 'Session expired or could not be found.' }); + } else { + const errorData = await response.json().catch(() => ({ message: 'Error fetching status' })); + throw new Error(errorData.message || `Server responded with ${response.status}`); + } + if (intervalId) clearInterval(intervalId); // Stop polling on error + return; + } + + const data: { status: UiState, address?: string, error?: string } = await response.json(); + console.log('Received status data:', data); + + // If status changed from pending, update UI and stop polling + if (data.status === 'success') { + setResultData({ address: data.address }); + setUiState('success'); + if (intervalId) clearInterval(intervalId); + } else if (data.status === 'failed') { + setResultData({ error: data.error || 'Authentication failed.' }); + setUiState('failed'); + if (intervalId) clearInterval(intervalId); + } + // If status is still 'pending', the interval will continue + + } catch (err) { + console.error('Error polling status:', err); + const message = err instanceof Error ? err.message : 'An unknown error occurred during status check'; + setError(`Status polling failed: ${message}`); + // Decide if we should stop polling or transition state on generic fetch error + // For now, let's stop polling and show error, moving to failed state + setResultData({ error: `Status polling failed: ${message}` }); + setUiState('failed'); + if (intervalId) clearInterval(intervalId); + } + }; + + // Start the interval + intervalId = setInterval(checkStatus, POLLING_INTERVAL); + + // Cleanup function to clear the interval when the component unmounts + // or when the dependencies (uiState, sessionId) change + return () => { + if (intervalId) { + console.log(`Stopping status polling for session: ${sessionId}`); + clearInterval(intervalId); + } + }; + }, [uiState, sessionId]); // Dependencies for the effect + + const handleStart = async () => { + setIsLoading(true); + setError(null); + setQrCodeDataUrl(null); // Clear previous QR code if any + setSessionId(null); + setResultData(null); + console.log('Requesting new DigiID session...'); + + try { + const response = await fetch('/api/digiid/start'); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Failed to start session. Server responded with status: ' + response.status })); + throw new Error(errorData.message || 'Failed to start session'); + } + const data = await response.json(); + console.log('Received session data:', data); + setSessionId(data.sessionId); + setQrCodeDataUrl(data.qrCodeDataUrl); + setUiState('waiting'); // Move to waiting state + } catch (err) { + console.error('Error starting DigiID session:', err); + const message = err instanceof Error ? err.message : 'An unknown error occurred'; + setError(`Failed to initiate DigiID: ${message}`); + setUiState('initial'); // Stay in initial state on error + } finally { + setIsLoading(false); + } + }; + + const handleReset = () => { + // TODO: Implement reset logic + console.log('Resetting flow...'); + setUiState('initial'); + setSessionId(null); + setQrCodeDataUrl(null); + setResultData(null); + setError(null); + setIsLoading(false); + }; + + return ( + <div className="app-container"> + <h1>DigiID-TS Demo</h1> + + {/* --- Initial State --- */} + {uiState === 'initial' && ( + <div className="initial-view"> + <h2>Welcome</h2> + <p>Click the button below to generate a DigiID login QR code.</p> + {/* TODO: Add Icon here */} + {/* <img src="/assets/YOUR_ICON_FILENAME.png" alt="DigiID Icon" width="100" /> */} + <button onClick={handleStart} disabled={isLoading}> + {isLoading ? 'Generating QR...' : 'Start DigiID Login'} + </button> + </div> + )} + + {/* --- Waiting State --- */} + {uiState === 'waiting' && qrCodeDataUrl && ( + <div className="waiting-view"> + <h2>Scan the QR Code</h2> + <p>Scan the QR code below using your DigiID compatible mobile wallet.</p> + <img src={qrCodeDataUrl} alt="DigiID QR Code" width="250" /> + <p>Waiting for authentication...</p> + {/* Optional: Add a cancel button here */} + <button onClick={handleReset}>Cancel</button> + </div> + )} + + {/* --- Success State --- */} + {uiState === 'success' && resultData?.address && ( + <div className="success-view"> + <h2>Authentication Successful!</h2> + <p>Verified Address:</p> + <p className="address">{resultData.address}</p> + <p>Address Type: {getDigiByteAddressType(resultData.address)}</p> + <button onClick={handleReset}>Start Over</button> + </div> + )} + + {/* --- Failed State --- */} + {uiState === 'failed' && ( + <div className="failed-view"> + <h2>Authentication Failed</h2> + <p className="error-message"> + Reason: {resultData?.error || error || 'An unknown error occurred.'} + </p> + <button onClick={handleReset}>Try Again</button> + </div> + )} + + {/* General error display (e.g., for initial start error) */} + {error && uiState === 'initial' && <p className="error-message">Error: {error}</p>} + </div> + ); +} + +export default App;
\ No newline at end of file diff --git a/src/client/index.css b/src/client/index.css new file mode 100644 index 0000000..2349c9b --- /dev/null +++ b/src/client/index.css @@ -0,0 +1,115 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.app-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; +} + +h1 { + font-size: 2.5em; + line-height: 1.1; + margin-bottom: 1rem; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} +button:disabled { + background-color: #333; + cursor: not-allowed; + opacity: 0.6; +} + +.initial-view, .waiting-view, .success-view, .failed-view { + padding: 1.5rem; + border: 1px solid #555; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + min-width: 300px; +} + +.waiting-view img { + background-color: white; /* Ensure QR code background is white */ + padding: 10px; + border-radius: 4px; +} + +.address { + font-family: monospace; + background-color: #333; + padding: 0.5em; + border-radius: 4px; + word-break: break-all; +} + +.error-message { + color: #ff6b6b; + font-weight: bold; +} + + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + button { + background-color: #f9f9f9; + } + .app-container { + /* Add light mode adjustments if needed */ + } + .initial-view, .waiting-view, .success-view, .failed-view { + border-color: #ccc; + } + .address { + background-color: #eee; + } +}
\ No newline at end of file diff --git a/src/client/main.tsx b/src/client/main.tsx new file mode 100644 index 0000000..b895c80 --- /dev/null +++ b/src/client/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; // We'll create this file later for styles + +ReactDOM.createRoot(document.getElementById('root')!).render( + <React.StrictMode> + <App /> + </React.StrictMode> +);
\ No newline at end of file diff --git a/src/client/utils.ts b/src/client/utils.ts new file mode 100644 index 0000000..cd9c8ba --- /dev/null +++ b/src/client/utils.ts @@ -0,0 +1,20 @@ +// Simple utility to determine DigiByte address type based on prefix + +export type DigiByteAddressType = 'DigiByte (DGB)' | 'DigiAsset (DGA)' | 'Unknown'; + +export function getDigiByteAddressType(address: string | undefined | null): DigiByteAddressType { + if (!address) { + return 'Unknown'; + } + if (address.startsWith('dgb1')) { + return 'DigiByte (DGB)'; + } + // Add other prefixes if DigiAssets use a distinct one, e.g., 'dga1' + // For now, assume non-DGB is DigiAsset, but this might need refinement + // depending on actual DigiAsset address formats. + else { + // Assuming DigiAssets might start differently or be the fallback + // This is a placeholder assumption. + return 'DigiAsset (DGA)'; // Placeholder - ADJUST BASED ON ACTUAL DGA PREFIX + } +}
\ No newline at end of file |