Abhyudit22
feat: complete midnight glassmorphism UI upgrade, thermal POS receipt printing, command palette, and rate limiting
1ce73ea | <script lang="ts"> | |
| import { onMount } from 'svelte'; | |
| // State Management | |
| let mode: 'login' | 'signup' | 'forgot' = 'login'; | |
| let username = ""; | |
| let email = ""; // Used for Sign Up & Forgot Password | |
| let password = ""; | |
| let errorMessage = ""; | |
| let isLoading = false; | |
| let showPassword = false; | |
| let rememberMe = false; | |
| import { Base_URL } from "./api"; | |
| export let onLoginSuccess: () => void; | |
| async function handleSubmit() { | |
| errorMessage = ""; | |
| isLoading = true; | |
| try { | |
| if (mode === 'login') { | |
| const formData = new URLSearchParams(); | |
| formData.append("username", username); | |
| formData.append("password", password); | |
| const response = await fetch(`${Base_URL}/auth/login`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/x-www-form-urlencoded" }, | |
| body: formData, | |
| }); | |
| if (response.status === 429) { | |
| const errData = await response.json().catch(() => ({})); | |
| throw new Error(errData.detail || "Too many attempts. Please wait 60 seconds."); | |
| } | |
| if (!response.ok) throw new Error("Invalid username or password credentials."); | |
| const data = await response.json(); | |
| localStorage.setItem("token", data.access_token); | |
| if (rememberMe) localStorage.setItem("stashgo_user", username); | |
| else localStorage.removeItem("stashgo_user"); | |
| onLoginSuccess(); | |
| } else if (mode === 'signup') { | |
| const response = await fetch(`${Base_URL}/auth/signup`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ username, email, password }), | |
| }); | |
| if (response.status === 429) { | |
| const errData = await response.json().catch(() => ({})); | |
| throw new Error(errData.detail || "Too many attempts. Please wait 60 seconds."); | |
| } | |
| if (!response.ok) { | |
| const errData = await response.json(); | |
| throw new Error(errData.detail || "Registration failed. Username or email may be taken."); | |
| } | |
| mode = 'login'; | |
| errorMessage = "Account created successfully! Please log in."; | |
| } else if (mode === 'forgot') { | |
| const response = await fetch(`${Base_URL}/auth/forgot-password`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ email: email.trim(), new_password: password }), | |
| }); | |
| if (response.status === 429) { | |
| const errData = await response.json().catch(() => ({})); | |
| throw new Error(errData.detail || "Too many attempts. Please wait 60 seconds."); | |
| } | |
| if (!response.ok) { | |
| const errData = await response.json(); | |
| throw new Error(errData.detail || "Password reset failed. Please check your email or username."); | |
| } | |
| const data = await response.json(); | |
| if (data.username) { | |
| username = data.username; | |
| } | |
| mode = 'login'; | |
| errorMessage = "Password reset successfully! You can now log in with your new password."; | |
| } | |
| } catch (error: any) { | |
| errorMessage = error.message || "Connection to backend failed."; | |
| } finally { | |
| isLoading = false; | |
| } | |
| } | |
| function toggleMode(targetMode: 'login' | 'signup' | 'forgot') { | |
| mode = targetMode; | |
| errorMessage = ""; | |
| password = ""; | |
| } | |
| onMount(() => { | |
| const savedUser = localStorage.getItem("stashgo_user"); | |
| if (savedUser) { | |
| username = savedUser; | |
| rememberMe = true; | |
| } | |
| }); | |
| </script> | |
| <div class="login-wrapper"> | |
| <div class="login-container animate-fade-in-up"> | |
| <div class="brand-header"> | |
| <div class="logo-icon animate-pulse-glow"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path> | |
| <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline> | |
| <line x1="12" y1="22.08" x2="12" y2="12"></line> | |
| </svg> | |
| </div> | |
| <h2> | |
| {#if mode === 'login'}Welcome Back | |
| {:else if mode === 'signup'}Create Account | |
| {:else}Reset Password | |
| {/if} | |
| </h2> | |
| <p class="subtitle"> | |
| {#if mode === 'login'}Secure Shopkeeper Terminal | |
| {:else if mode === 'signup'}Register for Stash GO | |
| {:else}Enter account email/username & new password | |
| {/if} | |
| </p> | |
| </div> | |
| <form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}> | |
| {#if mode === 'login'} | |
| <div class="input-group"> | |
| <label for="username">Username</label> | |
| <div class="input-wrapper"> | |
| <svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg> | |
| <input type="text" id="username" bind:value={username} required placeholder="e.g., admin" autocomplete="username"/> | |
| </div> | |
| </div> | |
| {/if} | |
| {#if mode === 'signup' || mode === 'forgot'} | |
| <div class="input-group animate-slide-down"> | |
| <label for="email">{mode === 'forgot' ? 'Email or Username' : 'Email Address'}</label> | |
| <div class="input-wrapper"> | |
| <svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| {#if mode === 'forgot'} | |
| <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle> | |
| {:else} | |
| <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline> | |
| {/if} | |
| </svg> | |
| <input | |
| type={mode === 'forgot' ? "text" : "email"} | |
| id="email" | |
| bind:value={email} | |
| required | |
| placeholder={mode === 'forgot' ? "store@example.com or admin" : "store@example.com"} | |
| autocomplete={mode === 'forgot' ? "username" : "email"} | |
| /> | |
| </div> | |
| </div> | |
| {/if} | |
| {#if mode === 'signup'} | |
| <div class="input-group animate-slide-down"> | |
| <label for="username">Username</label> | |
| <div class="input-wrapper"> | |
| <svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg> | |
| <input type="text" id="username" bind:value={username} required placeholder="e.g., admin" autocomplete="username"/> | |
| </div> | |
| </div> | |
| {/if} | |
| <div class="input-group"> | |
| <label for="password">{mode === 'forgot' ? 'New Password' : 'Password'}</label> | |
| <div class="input-wrapper"> | |
| <svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg> | |
| <input type={showPassword ? "text" : "password"} id="password" bind:value={password} required placeholder="••••••••" autocomplete={mode === 'login' ? 'current-password' : 'new-password'}/> | |
| <button type="button" class="toggle-password" onclick={() => showPassword = !showPassword} tabindex="-1"> | |
| {#if showPassword} | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg> | |
| {:else} | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg> | |
| {/if} | |
| </button> | |
| </div> | |
| </div> | |
| {#if mode === 'login'} | |
| <div class="form-options"> | |
| <label class="remember-me"> | |
| <input type="checkbox" bind:checked={rememberMe} /> | |
| <span>Remember me</span> | |
| </label> | |
| <button type="button" class="forgot-link" onclick={() => toggleMode('forgot')}>Forgot password?</button> | |
| </div> | |
| {/if} | |
| {#if errorMessage} | |
| <div class="error-box animate-slide-down" style={errorMessage.includes('successfully') ? 'color: var(--accent-success); background: rgba(16, 185, 129, 0.12); border-color: rgba(16, 185, 129, 0.3);' : ''}> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> | |
| <p>{errorMessage}</p> | |
| </div> | |
| {/if} | |
| <button type="submit" class="btn-submit" disabled={isLoading}> | |
| {#if isLoading} | |
| <svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg> | |
| {#if mode === 'login'}Authenticating... | |
| {:else if mode === 'signup'}Registering... | |
| {:else}Resetting Password... | |
| {/if} | |
| {:else} | |
| {#if mode === 'login'}Secure Login | |
| {:else if mode === 'signup'}Create Account | |
| {:else}Update Password | |
| {/if} | |
| {/if} | |
| </button> | |
| </form> | |
| <div class="toggle-mode"> | |
| {#if mode === 'login'} | |
| Don't have an account? <button type="button" class="link-btn" onclick={() => toggleMode('signup')}>Sign up</button> | |
| {:else if mode === 'signup'} | |
| Already have an account? <button type="button" class="link-btn" onclick={() => toggleMode('login')}>Log in</button> | |
| {:else} | |
| Remembered your password? <button type="button" class="link-btn" onclick={() => toggleMode('login')}>Log in</button> | |
| {/if} | |
| </div> | |
| </div> | |
| </div> | |
| <style> | |
| .login-wrapper { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: calc(100vh - 120px); | |
| background-color: var(--bg-app); | |
| background-image: | |
| radial-gradient(at 0% 0%, rgba(15, 90, 71, 0.06) 0px, transparent 50%), | |
| radial-gradient(at 100% 100%, rgba(16, 185, 129, 0.06) 0px, transparent 50%); | |
| } | |
| .login-container { | |
| width: 100%; | |
| max-width: 420px; | |
| padding: 40px; | |
| background: var(--bg-card); | |
| border: 1px solid var(--border-light); | |
| border-radius: var(--radius-lg); | |
| box-shadow: var(--shadow-lg); | |
| color: var(--text-main); | |
| } | |
| .brand-header { text-align: center; margin-bottom: 32px; } | |
| .logo-icon { | |
| width: 52px; | |
| height: 52px; | |
| margin: 0 auto 16px; | |
| color: var(--accent-primary); | |
| background: rgba(15, 90, 71, 0.08); | |
| padding: 12px; | |
| border-radius: 16px; | |
| border: 1px solid rgba(15, 90, 71, 0.2); | |
| } | |
| h2 { margin: 0 0 6px 0; font-size: 26px; font-weight: 800; letter-spacing: -0.5px; color: var(--accent-primary); } | |
| .subtitle { margin: 0; font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1.5px; font-weight: 700; } | |
| .input-group { margin-bottom: 22px; } | |
| .input-group label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 600; color: var(--text-muted); transition: color 0.2s; } | |
| .input-group:focus-within label { color: var(--accent-primary); } | |
| .input-wrapper { position: relative; display: flex; align-items: center; } | |
| .input-icon { position: absolute; left: 14px; width: 18px; height: 18px; color: var(--text-muted); transition: color 0.2s; pointer-events: none; } | |
| .input-group:focus-within .input-icon { color: var(--accent-primary); } | |
| input { | |
| width: 100%; | |
| padding: 14px 44px 14px 44px; | |
| border: 1px solid var(--border-solid); | |
| border-radius: 10px; | |
| background: #ffffff; | |
| color: #0f172a ; | |
| font-size: 15px; | |
| font-weight: 600; | |
| transition: all 0.25s ease; | |
| } | |
| input:focus { | |
| outline: none; | |
| border-color: var(--accent-primary); | |
| box-shadow: 0 0 0 3px rgba(15, 90, 71, 0.12); | |
| } | |
| .toggle-password { position: absolute; right: 14px; background: none; border: none; padding: 0; color: var(--text-muted); cursor: pointer; display: flex; align-items: center; } | |
| .toggle-password:hover { color: var(--accent-primary); } | |
| .toggle-password svg { width: 18px; height: 18px; } | |
| .form-options { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; font-size: 13px; } | |
| .remember-me { display: flex; align-items: center; gap: 8px; color: var(--text-muted); cursor: pointer; margin: 0; } | |
| .remember-me:hover { color: var(--text-main); } | |
| .remember-me input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent-primary); cursor: pointer; } | |
| .forgot-link, .link-btn { background: none; border: none; padding: 0; font: inherit; font-size: 13px; color: var(--accent-primary); cursor: pointer; text-decoration: none; font-weight: 600; } | |
| .forgot-link:hover, .link-btn:hover { color: var(--accent-mint); text-decoration: underline; } | |
| .btn-submit { | |
| width: 100%; | |
| padding: 14px; | |
| background: linear-gradient(135deg, #0f5a47 0%, #0b4536 100%); | |
| color: #ffffff; | |
| border: none; | |
| border-radius: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.5px; | |
| cursor: pointer; | |
| font-size: 15px; | |
| transition: all 0.25s ease; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 8px; | |
| box-shadow: 0 4px 12px rgba(15, 90, 71, 0.25); | |
| } | |
| .btn-submit:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 18px rgba(15, 90, 71, 0.35); | |
| } | |
| .toggle-mode { text-align: center; font-size: 13px; color: var(--text-muted); margin-top: 28px; padding-top: 24px; border-top: 1px solid var(--border-light); } | |
| .spinner { width: 18px; height: 18px; animation: spin 1s linear infinite; } | |
| .error-box { display: flex; align-items: center; gap: 12px; color: var(--accent-danger); background: rgba(225, 29, 72, 0.08); border: 1px solid rgba(225, 29, 72, 0.2); padding: 14px; border-radius: 10px; margin-bottom: 24px; } | |
| .error-box svg { width: 20px; height: 20px; flex-shrink: 0; } | |
| .error-box p { margin: 0; font-size: 13px; font-weight: 600; } | |
| @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } | |
| .animate-fade-in-up { animation: fadeInUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; } | |
| .animate-slide-down { animation: slideDown 0.3s ease-out forwards; } | |
| .animate-pulse-glow { animation: pulseGlow 3s infinite alternate ease-in-out; } | |
| @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes pulseGlow { 0% { box-shadow: 0 0 0 0 rgba(15, 90, 71, 0.3); } 100% { box-shadow: 0 0 15px 3px rgba(15, 90, 71, 0.1); } } | |
| </style> |