diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..d6257d4 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,17 @@ +## Design Context + +### Users +Developer-founders and indie hackers building SaaS products. They use Plunk to handle transactional and marketing email without the complexity of tools like Mailchimp or Customer.io. They notice tiny details — inconsistent spacing, placeholder text that adds no value, a button that doesn't communicate state. Context: professional environment, desktop-first. + +### Brand Personality +Sharp, minimal, confident. The product earns trust by being simple and correct, not by being flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design" — the brand is *care without noise*. + +### Aesthetic Direction +Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors. No color for decoration — only for semantics (red = error, green = success). Backgrounds are near-white with subtle texture. Cards use white with a neutral border and light shadow. Typography should feel precise and legible, not editorial. Spacing should feel considered, not generous. + +### Design Principles +1. **Every pixel earns its place.** If something doesn't communicate information or provide affordance, remove it. +2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning states, not decoration. +3. **Interaction should feel fast.** Loading states communicate exactly what's happening. No silent actions. +4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they add value. Labels are unambiguous. +5. **Consistency is trust.** The same pattern everywhere. One way to show errors. One way to show success. No creative variation in functional UI. diff --git a/apps/web/src/pages/auth/login.tsx b/apps/web/src/pages/auth/login.tsx index a5d4f6c..608f808 100644 --- a/apps/web/src/pages/auth/login.tsx +++ b/apps/web/src/pages/auth/login.tsx @@ -20,6 +20,7 @@ import { } from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {NextSeo} from 'next-seo'; +import Image from 'next/image'; import Link from 'next/link'; import {useRouter} from 'next/router'; import React, {useState} from 'react'; @@ -27,11 +28,22 @@ import {useForm} from 'react-hook-form'; import type {z} from 'zod'; import {API_URI} from '../../lib/constants'; +import {useConfig} from '../../lib/hooks/useConfig'; import {useProjects} from '../../lib/hooks/useProject'; import {useUser} from '../../lib/hooks/useUser'; -import {useConfig} from '../../lib/hooks/useConfig'; import {network} from '../../lib/network'; +const Spinner = () => ( + + + + +); + export default function Login() { const {mutate: userMutate} = useUser(); const {mutate: projectsMutate} = useProjects(); @@ -67,7 +79,7 @@ export default function Login() { >('POST', '/auth/login', values); if (!response.success) { - setErrorMessage('Email or password is not correct'); + setErrorMessage('Email or password is incorrect'); } else { setErrorMessage(null); @@ -108,9 +120,23 @@ export default function Login() { return ( <> - -
-
+ +
+
+
+
+ +
+ Plunk +
+
@@ -122,9 +148,9 @@ export default function Login() { className="p-8" >
-
-

Welcome back

-

Enter your credentials to access your account

+
+

Welcome back

+

Sign in to your account

{(oauthConfig.github || oauthConfig.google) && ( @@ -139,7 +165,7 @@ export default function Login() { window.location.href = `${API_URI}/oauth/google/outbound`; }} > - + - + Continue with GitHub @@ -178,16 +204,16 @@ export default function Login() {
- +
- Or continue with email + or
)} -
+
Email - + )} /> -
-
+ ( - Password +
+ Password + +
- + -
)} /> -
{errorMessage && ( {errorMessage} )} - - - + -
+

Don't have an account?{' '} - + Sign up -

+

@@ -307,22 +314,30 @@ export default function Login() { > setResetEmail(e.target.value)} required + autoFocus /> -
- {resetStatus === 'success' && ( -

+

If an account exists, a reset link has been sent to your email.

)} - {resetStatus === 'error' &&

{resetError}

} + {resetStatus === 'error' &&

{resetError}

}
diff --git a/apps/web/src/pages/auth/reset-password.tsx b/apps/web/src/pages/auth/reset-password.tsx index e286ce9..b3d7a0c 100644 --- a/apps/web/src/pages/auth/reset-password.tsx +++ b/apps/web/src/pages/auth/reset-password.tsx @@ -14,6 +14,7 @@ import { } from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {NextSeo} from 'next-seo'; +import Image from 'next/image'; import Link from 'next/link'; import {useRouter} from 'next/router'; import React, {useEffect, useState} from 'react'; @@ -22,6 +23,32 @@ import type {z} from 'zod'; import {network} from '../../lib/network'; +const dotGrid = { + backgroundColor: '#fafafa', + backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)', + backgroundSize: '20px 20px', +}; + +const Spinner = () => ( + + + + +); + +const Wordmark = () => ( +
+
+ +
+ Plunk +
+); + export default function ResetPassword() { const router = useRouter(); const {token} = router.query; @@ -37,7 +64,6 @@ export default function ResetPassword() { }, }); - // Update form token when router is ready useEffect(() => { if (token && typeof token === 'string') { form.setValue('token', token); @@ -71,22 +97,25 @@ export default function ResetPassword() { return ( <> -
+
+
-
- +
+
-

Invalid reset link

-

- This password reset link is invalid. Please request a new one from the login page. -

- - +
+

Invalid reset link

+

+ This link is invalid or has expired. Request a new one from the login page. +

+
+ +
@@ -100,29 +129,31 @@ export default function ResetPassword() { return ( <> -
+
+ {status === 'success' ? (
-
- +
+
-

Password reset!

-

- Your password has been successfully reset. Redirecting to login... -

+
+

Password updated

+

Redirecting you to login...

+
) : ( @@ -135,34 +166,33 @@ export default function ResetPassword() { }} >
-
-

Reset your password

-

Enter your new password below

+
+

Reset your password

+

Enter your new password below

-
- ( - - New Password - - - - - - )} - /> -
+ ( + + New password + + + + + + )} + /> {status === 'error' && ( {errorMessage} @@ -172,38 +202,23 @@ export default function ResetPassword() { -
+

Remember your password?{' '} - + Back to login -

+

diff --git a/apps/web/src/pages/auth/signup.tsx b/apps/web/src/pages/auth/signup.tsx index fc9dbb2..de958f1 100644 --- a/apps/web/src/pages/auth/signup.tsx +++ b/apps/web/src/pages/auth/signup.tsx @@ -14,6 +14,7 @@ import { } from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {NextSeo} from 'next-seo'; +import Image from 'next/image'; import Link from 'next/link'; import {useRouter} from 'next/router'; import React, {useState} from 'react'; @@ -21,11 +22,22 @@ import {useForm} from 'react-hook-form'; import type {z} from 'zod'; import {API_URI} from '../../lib/constants'; +import {useConfig} from '../../lib/hooks/useConfig'; import {useProjects} from '../../lib/hooks/useProject'; import {useUser} from '../../lib/hooks/useUser'; -import {useConfig} from '../../lib/hooks/useConfig'; import {network} from '../../lib/network'; +const Spinner = () => ( + + + + +); + export default function Signup() { const {mutate: userMutate} = useUser(); const {mutate: projectsMutate} = useProjects(); @@ -57,7 +69,6 @@ export default function Signup() { >('POST', '/auth/signup', values); if (!response.success) { - // Handle error message from API const errorData = typeof response.data === 'string' ? response.data : 'Something went wrong'; setErrorMessage(errorData); } else { @@ -76,8 +87,22 @@ export default function Signup() { return ( <> -
-
+
+
+
+
+ +
+ Plunk +
+
@@ -89,9 +114,9 @@ export default function Signup() { className="p-8" >
-
-

Create an account

-

Get started with Plunk today

+
+

Create an account

+

Start sending emails in minutes

{(oauthConfig.github || oauthConfig.google) && ( @@ -106,7 +131,7 @@ export default function Signup() { window.location.href = `${API_URI}/oauth/google/outbound`; }} > - + - + Continue with GitHub @@ -145,16 +170,16 @@ export default function Signup() {
- +
- Or continue with email + or
)} -
+
Email - + )} /> -
-
+ Password - + @@ -188,53 +212,34 @@ export default function Signup() { {errorMessage && ( {errorMessage} )} - - - + -
+

Already have an account?{' '} - - Login + + Log in -

+

diff --git a/apps/web/src/pages/auth/verify-email.tsx b/apps/web/src/pages/auth/verify-email.tsx index 03a9a88..95f0dcf 100644 --- a/apps/web/src/pages/auth/verify-email.tsx +++ b/apps/web/src/pages/auth/verify-email.tsx @@ -2,12 +2,30 @@ import {AuthenticationSchemas} from '@plunk/shared'; import {Button, Card, CardContent} from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {NextSeo} from 'next-seo'; +import Image from 'next/image'; import Link from 'next/link'; import {useRouter} from 'next/router'; import React, {useEffect, useRef, useState} from 'react'; import {network} from '../../lib/network'; +const dotGrid = { + backgroundColor: '#fafafa', + backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)', + backgroundSize: '20px 20px', +}; + +const Spinner = () => ( + + + + +); + export default function VerifyEmail() { const router = useRouter(); const {token} = router.query; @@ -21,7 +39,6 @@ export default function VerifyEmail() { const processedToken = useRef(undefined); useEffect(() => { - // Wait for router to be ready before processing if (!router.isReady) { return; } @@ -34,7 +51,6 @@ export default function VerifyEmail() { processedToken.current = normalizedToken; - // If no token, show the pending verification state if (!token || typeof token !== 'string') { setStatus('pending'); return; @@ -69,29 +85,24 @@ export default function VerifyEmail() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.isReady, token]); - // Initialize cooldown from localStorage on mount useEffect(() => { const storedExpiry = localStorage.getItem('plunk:email-verification-cooldown'); if (storedExpiry) { const expiryTime = parseInt(storedExpiry, 10); - // Validate: not NaN, in the future, and within reasonable range (< 1 hour from now) if (!isNaN(expiryTime) && expiryTime > Date.now() && expiryTime < Date.now() + 3600000) { setCooldownExpiry(expiryTime); } else { - // Clean up invalid/expired cooldown localStorage.removeItem('plunk:email-verification-cooldown'); } } }, []); - // Countdown timer effect useEffect(() => { if (!cooldownExpiry) { setRemainingSeconds(0); return; } - // Update immediately const updateRemaining = () => { const remaining = Math.max(0, Math.ceil((cooldownExpiry - Date.now()) / 1000)); setRemainingSeconds(remaining); @@ -116,7 +127,6 @@ export default function VerifyEmail() { if (response.success) { setResendMessage('Verification email sent! Please check your inbox.'); - // Set 60-second cooldown const expiryTime = Date.now() + 60000; setCooldownExpiry(expiryTime); localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString()); @@ -124,9 +134,7 @@ export default function VerifyEmail() { setResendMessage('Failed to send verification email. Please try again.'); } } catch (error) { - // Show error message but still apply cooldown to prevent spam setResendMessage(error instanceof Error ? error.message : 'Failed to send verification email. Please try again.'); - // Apply cooldown even on error to prevent retry spam const expiryTime = Date.now() + 60000; setCooldownExpiry(expiryTime); localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString()); @@ -138,8 +146,15 @@ export default function VerifyEmail() { return ( <> -
+
+
+
+ +
+ Plunk +
+
@@ -147,13 +162,14 @@ export default function VerifyEmail() { {status === 'pending' && ( -
- +
+
-

Verify your email

-

- Please check your inbox for a verification link. Click the link in the email to verify your - account. -

+
+

Check your email

+

+ We sent a verification link to your inbox. Click it to verify your account. +

+
-
+
{resendMessage && ( -

+

{resendMessage}

)} @@ -196,73 +215,70 @@ export default function VerifyEmail() { initial={{opacity: 0}} animate={{opacity: 1}} exit={{opacity: 0}} + transition={{duration: 0.2}} className="flex flex-col items-center gap-4" > -
- - - - +
+ +
+
+

Verifying...

+

Please wait a moment.

-

Verifying your email...

-

Please wait while we verify your email address.

)} {status === 'success' && ( -
- +
+
-

Email verified!

-

- Your email has been successfully verified. Redirecting to dashboard... -

+
+

Email verified

+

Redirecting to your dashboard...

+
)} {status === 'error' && ( -
- +
+
-

Verification failed

-

{errorMessage}

+
+

Verification failed

+

{errorMessage}

+
-
+
{resendMessage && ( -

+

{resendMessage}

)} diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index e641c78..c2e814e 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -5,6 +5,7 @@ import {ProjectSchemas, SUPPORTED_LANGUAGES} from '@plunk/shared'; import {TrackingMode} from '@plunk/db'; import { Alert, + AlertDescription, Button, Card, CardContent, @@ -825,14 +826,14 @@ export default function Settings() { Regenerate API Keys - +

Are you sure you want to regenerate your API keys?

- + -
- Warning: This action will immediately invalidate your current API keys. Any - applications using the old keys will stop working until you update them with the new keys. -
+ + Current keys will be immediately invalidated. Any integrations using the old keys + will stop working until updated. +