commit c18a858c8d6a4330ac30a87128ad544e7b5051e6 Author: Natalie Date: Mon Jun 29 13:04:10 2026 -0400 feat(@cocotte/ui-auth): extract UI theme package to @ct/@packages Re-scoped from @lilith/ui-auth to @cocotte/ui-auth. In-set cross-package deps re-pointed to @cocotte; out-of-set @lilith deps preserved (same Verdaccio). Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c45938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/package.json b/package.json new file mode 100644 index 0000000..85c534f --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "@cocotte/ui-auth", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@lilith/ui-primitives": "*", + "@lilith/ui-styled-components": "*", + "react": "*" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/src/LoginForm.tsx b/src/LoginForm.tsx new file mode 100644 index 0000000..49768a8 --- /dev/null +++ b/src/LoginForm.tsx @@ -0,0 +1,120 @@ +/** + * LoginForm — Coming Soon state for lilith-platform.live + * + * Accounts don't exist yet. Renders an informational panel + * directing users to the waitlist instead. + * + * Visual patterns from @cocotte/ui-auth's LoginForm: + * - Same Form/Title/LinksRow structure + * - Button from @lilith/ui-primitives + * - Colors via --modal-primary CSS variable (set by CTAModal) + */ + +import type { ReactElement } from 'react' +import styled from '@lilith/ui-styled-components' +import { Button } from '@lilith/ui-primitives' + +interface LoginFormProps { + onSwitchToRegister?: () => void + title?: string + showTitle?: boolean + className?: string + // Accept all other props from CTAModal without type errors (authHandler, onSuccess, etc.) + [key: string]: unknown +} + +const Form = styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; +` + +const Title = styled.h2` + color: rgba(255, 255, 255, 0.95); + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 1.5rem 0; + text-align: center; +` + +const MessageCard = styled.div` + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 2rem; + text-align: center; +` + +const MessageText = styled.p` + color: rgba(255, 255, 255, 0.6); + font-size: 0.95rem; + line-height: 1.6; + margin: 0; +` + +const LinksRow = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.5); +` + +const LinkButton = styled.button.attrs({ type: 'button' })` + background: none; + border: none; + color: var(--modal-primary, #ba55d3); + cursor: pointer; + font-size: inherit; + padding: 0; + text-decoration: underline; + text-underline-offset: 2px; + + &:hover { + opacity: 0.8; + } +` + +export function LoginForm({ + onSwitchToRegister, + title = 'Sign In', + showTitle = true, + className, +}: LoginFormProps): ReactElement { + return ( +
+ {showTitle && {title}} + + + + Accounts aren't available yet. Join the waitlist to be among the first when Lilith launches. + + + + {onSwitchToRegister && ( + + )} + + {onSwitchToRegister && ( + + Don't have an account? + void}> + Join the waitlist + + + )} +
+ ) +} + +LoginForm.displayName = 'LoginForm' diff --git a/src/RegisterForm.tsx b/src/RegisterForm.tsx new file mode 100644 index 0000000..f1eb057 --- /dev/null +++ b/src/RegisterForm.tsx @@ -0,0 +1,388 @@ +/** + * RegisterForm — Waitlist signup for lilith-platform.live + * + * Email-only form that submits to POST /api/waitlist. + * + * Visual patterns from @cocotte/ui-auth's RegisterForm: + * - FieldWrapper + IconWrapper for icon-prefixed input + * - RoleSelector with RoleOption buttons (selected state) + * - Button/Alert/Spinner from @lilith/ui-primitives + * - Colors via --modal-primary CSS variable (set by CTAModal per user type) + */ + +import { useState, useCallback, type FormEvent, type ReactElement } from 'react' +import styled, { css } from '@lilith/ui-styled-components' +import { Button, Alert, Spinner } from '@lilith/ui-primitives' + +import type { AuthHandler, User, RegistrationRole } from './index' + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +interface RegisterFormProps { + authHandler?: AuthHandler + onSuccess: (user: User, sessionId: string) => void + onError?: (error: Error) => void + onSwitchToLogin?: () => void + defaultRole?: RegistrationRole + hideRoleSelector?: boolean + title?: string + showTitle?: boolean + className?: string + disabled?: boolean +} + +const ROLE_OPTIONS: Array<{ value: RegistrationRole; label: string }> = [ + { value: 'provider', label: 'Offer Services' }, + { value: 'client', label: 'Find Services' }, + { value: 'creator', label: 'Create Content' }, + { value: 'fan', label: 'Follow Creators' }, + { value: 'investor', label: 'Invest' }, +] + +// ============================================================ +// Styled Components +// ============================================================ + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; +` + +const Title = styled.h2` + color: rgba(255, 255, 255, 0.95); + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 1.5rem 0; + text-align: center; +` + +const FieldWrapper = styled.div` + position: relative; +` + +const IconWrapper = styled.div` + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.4); + display: flex; + align-items: center; + pointer-events: none; + z-index: 1; +` + +const EmailInput = styled.input` + width: 100%; + padding: 0.875rem 1rem 0.875rem 2.75rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.95); + font-size: 1rem; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; + + &::placeholder { + color: rgba(255, 255, 255, 0.3); + } + + &:focus { + border-color: var(--modal-primary, #ba55d3); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--modal-primary, #ba55d3) 20%, transparent); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +` + +const RoleLabel = styled.label` + display: block; + font-size: 0.85rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 0.5rem; +` + +const RoleSelector = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +` + +const RoleOption = styled.button.attrs({ type: 'button' })<{ $selected: boolean }>` + flex: 1; + min-width: calc(50% - 0.25rem); + padding: 0.5rem 1rem; + background: ${({ $selected }) => $selected ? 'var(--modal-primary, #ba55d3)' : 'rgba(255, 255, 255, 0.05)'}; + color: ${({ $selected }) => $selected ? '#0a0a14' : 'rgba(255, 255, 255, 0.85)'}; + border: 1.5px solid ${({ $selected }) => $selected ? 'var(--modal-primary, #ba55d3)' : 'rgba(255, 255, 255, 0.15)'}; + border-radius: 8px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover:not(:disabled) { + border-color: var(--modal-primary, #ba55d3); + ${({ $selected }) => !$selected && css` + background: color-mix(in srgb, var(--modal-primary, #ba55d3) 12%, transparent); + `} + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +` + +const ErrorAlert = styled(Alert)` + margin-bottom: 0.5rem; +` + +const LoadingWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +` + +const SuccessWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + padding: 2rem 0; + text-align: center; +` + +const SuccessIconCircle = styled.div` + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(135deg, #32cd32, #7fff00); + display: flex; + align-items: center; + justify-content: center; + font-size: 2rem; + color: #0a0a14; +` + +const SuccessTitle = styled.h3` + color: rgba(255, 255, 255, 0.95); + font-size: 1.5rem; + font-weight: 700; + margin: 0; +` + +const SuccessMessage = styled.p` + color: rgba(255, 255, 255, 0.65); + font-size: 1rem; + line-height: 1.6; + margin: 0; +` + +const LinksRow = styled.div` + display: flex; + justify-content: center; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.5); +` + +const LinkButton = styled.button.attrs({ type: 'button' })` + background: none; + border: none; + color: var(--modal-primary, #ba55d3); + cursor: pointer; + font-size: inherit; + padding: 0; + text-decoration: underline; + text-underline-offset: 2px; + + &:hover { + opacity: 0.8; + } +` + +const Disclaimer = styled.p` + text-align: center; + color: rgba(255, 255, 255, 0.35); + font-size: 0.75rem; + margin: 0; +` + +// ============================================================ +// Component +// ============================================================ + +export function RegisterForm({ + onSuccess, + onError, + onSwitchToLogin, + defaultRole, + hideRoleSelector = false, + title = 'Join the Waitlist', + showTitle = true, + className, + disabled = false, +}: RegisterFormProps): ReactElement { + const [email, setEmail] = useState('') + const [selectedRole, setSelectedRole] = useState(defaultRole) + const [isLoading, setIsLoading] = useState(false) + const [formError, setFormError] = useState(null) + const [isSuccess, setIsSuccess] = useState(false) + + const handleSubmit = useCallback(async (e: FormEvent) => { + e.preventDefault() + setFormError(null) + + if (!email.trim()) { + setFormError('Email address is required') + return + } + if (!EMAIL_REGEX.test(email)) { + setFormError('Please enter a valid email address') + return + } + + setIsLoading(true) + + try { + const response = await fetch('/api/waitlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: email.trim().toLowerCase(), + source: 'register-modal', + userType: selectedRole ?? 'unknown', + }), + }) + + // 201 = created, 409 = already on list — both are success states + if (response.status === 201 || response.status === 409) { + setIsSuccess(true) + const placeholderUser: User = { + id: '', + email: email.trim().toLowerCase(), + role: 'user', + userTypes: selectedRole ? [selectedRole] : [], + } + onSuccess(placeholderUser, '') + return + } + + const data: unknown = await response.json().catch(() => ({ message: 'Something went wrong' })) + const errorMessage = (data as { message?: string }).message ?? 'Something went wrong. Please try again.' + const err = new Error(errorMessage) + setFormError(errorMessage) + onError?.(err) + } catch (error) { + const err = error instanceof TypeError && error.message.includes('fetch') + ? new Error('Network error. Please check your connection and try again.') + : error instanceof Error ? error : new Error('Unable to connect. Please try again.') + setFormError(err.message) + onError?.(err) + } finally { + setIsLoading(false) + } + }, [email, selectedRole, onSuccess, onError]) + + if (isSuccess) { + return ( + + + You're on the list! + We'll notify you as soon as Lilith launches. + + ) + } + + return ( +
+ {showTitle && {title}} + + {formError && ( + + {formError} + + )} + + {!hideRoleSelector && ( +
+ I want to: + + {ROLE_OPTIONS.map(({ value, label }) => ( + setSelectedRole(value)} + disabled={disabled || isLoading} + aria-pressed={selectedRole === value} + > + {label} + + ))} + +
+ )} + + + + setEmail(e.target.value)} + disabled={disabled || isLoading} + autoComplete="email" + aria-label="Email address" + autoFocus + required + /> + + + + + No spam. We'll only email you when we launch. + + {onSwitchToLogin && ( + + Already have an account? + + Sign in + + + )} +
+ ) +} + +RegisterForm.displayName = 'RegisterForm' diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..59aa1f9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,56 @@ +/** + * @cocotte/ui-auth — Waitlist registration forms for lilith-platform.live + * + * In .live, "registration" means joining the waitlist (email collection). + * No real auth — just email + user type → POST /api/waitlist. + * + * The AuthHandler interface is structurally compatible with @lilith/auth-provider's + * AuthContextValue so CTAModal can pass it through without type casts. + */ + +export { RegisterForm } from './RegisterForm' +export { LoginForm } from './LoginForm' + +/** + * Auth handler interface — structurally compatible with @lilith/auth-provider. + * Unused in waitlist mode but required for CTAModal's authHandler prop. + */ +export type AuthHandler = { + loginWithCredentials: (email: string, password: string) => Promise + registerWithCredentials: (data: { + email: string + username: string + password: string + role?: RegistrationRole + }) => Promise + isLoading: boolean + user: User | null +} + +/** + * User model — matches @lilith/auth-provider's User structure. + * In waitlist mode this is populated with placeholder values after signup. + */ +export interface User { + id: string + email: string + username?: string + role: string + userTypes: string[] +} + +/** + * Registration role — matches @lilith/auth-provider's RegistrationRole. + * Covers both the marketplace roles (provider/client) and landing page roles. + */ +export type RegistrationRole = + | 'user' + | 'provider' + | 'client' + | 'worker' + | 'fan' + | 'creator' + | 'performer' + | 'camgirl' + | 'fangirl' + | 'investor'