feat(landing): multi-type user model with primary selection

- Users can now have multiple account types simultaneously
- Added canBePrimary() to prevent User type being primary when other types exist
- DevUserSwitcher: multi-select checkboxes with star buttons for primary
- ProfilePage: type management with toggle and primary selection
- UserMenu: shows primary type with +N badge for additional types
- Auto-switches primary away from User when adding declared types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-28 19:46:29 -08:00
parent e527115c6a
commit b40b3f6fe9
6 changed files with 409 additions and 103 deletions

View file

@ -1,4 +1,5 @@
/* Dev User Switcher - Only visible in development mode */
/* Supports multi-type selection with primary type */
.dev-user-switcher {
position: fixed;
@ -60,7 +61,7 @@
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 280px;
width: 320px;
background: rgba(20, 20, 25, 0.98);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
@ -119,34 +120,118 @@
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
}
/* Quick actions */
.dev-user-switcher-actions {
padding: 8px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.dev-user-action {
width: 100%;
padding: 8px 12px;
background: rgba(168, 85, 247, 0.2);
border: 1px solid rgba(168, 85, 247, 0.3);
border-radius: 6px;
color: #a855f7;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.dev-user-action:hover {
background: rgba(168, 85, 247, 0.3);
border-color: rgba(168, 85, 247, 0.5);
}
.dev-user-action--danger {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.dev-user-action--danger:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.5);
}
/* Section label */
.dev-user-switcher-section-label {
padding: 10px 16px 6px;
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dev-user-switcher-hint {
font-weight: 400;
text-transform: none;
letter-spacing: normal;
margin-left: 4px;
}
/* Options list */
.dev-user-switcher-options {
padding: 8px;
padding: 4px 8px 8px;
}
.dev-user-option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
background: transparent;
border: 1px solid transparent;
gap: 4px;
margin-bottom: 4px;
border-radius: 8px;
color: #fff;
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
}
.dev-user-option:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
.dev-user-option:last-child {
margin-bottom: 0;
}
.dev-user-option.active {
background: rgba(168, 85, 247, 0.15);
border-color: rgba(168, 85, 247, 0.3);
background: rgba(168, 85, 247, 0.1);
}
/* Toggle button (checkbox + icon + content) */
.dev-user-option-toggle {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
padding: 10px 12px;
background: transparent;
border: none;
color: #fff;
cursor: pointer;
text-align: left;
border-radius: 8px;
transition: all 0.15s ease;
}
.dev-user-option-toggle:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Checkbox */
.dev-user-option-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
font-size: 11px;
flex-shrink: 0;
transition: all 0.15s ease;
}
.dev-user-option-checkbox.checked {
background: #a855f7;
border-color: #a855f7;
color: #fff;
}
.dev-user-option-icon {
@ -171,10 +256,34 @@
color: rgba(255, 255, 255, 0.5);
}
.dev-user-option-check {
color: #a855f7;
font-weight: bold;
/* Primary star button */
.dev-user-option-primary {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: 6px;
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
.dev-user-option-primary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
}
.dev-user-option-primary.is-primary {
color: #f59e0b;
}
.dev-user-option-primary:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Footer */
@ -205,5 +314,6 @@
.dev-user-switcher-panel {
left: auto;
right: 0;
width: 300px;
}
}

View file

@ -1,29 +1,12 @@
import { useState, useEffect, useCallback } from 'react'
import { Star } from 'lucide-react'
import { useDevUser, DEV_USER_TYPES, type DevUserType } from '../contexts/DevUserContext'
import { useDevUser, getUserTypeEmoji, DEV_USER_TYPES, type DevUserType } from '../contexts'
import './DevUserSwitcher.css'
/** Get icon for each user type */
function getUserTypeIcon(type: DevUserType): string {
switch (type) {
case 'guest':
return '👻'
case 'registered-user':
return '👤'
case 'registered-provider':
return '🎭'
case 'registered-client':
return '💜'
case 'registered-investor':
return '💎'
}
}
/** Get short label for each user type */
function getShortLabel(type: DevUserType): string {
switch (type) {
case 'guest':
return 'Guest'
case 'registered-user':
return 'User'
case 'registered-provider':
@ -38,8 +21,6 @@ function getShortLabel(type: DevUserType): string {
/** Get description for each user type */
function getDescription(type: DevUserType): string {
switch (type) {
case 'guest':
return 'Not authenticated'
case 'registered-user':
return 'Registered via shop, no declared intent'
case 'registered-provider':
@ -53,10 +34,23 @@ function getDescription(type: DevUserType): string {
/**
* Dev-only floating switcher for simulating different user types
* Supports multi-type selection with primary type
* Only renders in development mode
*/
export default function DevUserSwitcher() {
const { userType, setUserType, isDevMode, isAuthenticated, hasDeclaredIntent } = useDevUser()
const {
userTypes,
primaryType,
isDevMode,
isAuthenticated,
hasDeclaredIntent,
toggleType,
setPrimaryType,
signOut,
signInAsUser,
hasType,
canBePrimary,
} = useDevUser()
const [isExpanded, setIsExpanded] = useState(false)
const toggleExpanded = useCallback(() => {
@ -81,6 +75,9 @@ export default function DevUserSwitcher() {
// Don't render in production
if (!isDevMode) return null
const displayLabel = primaryType ? getShortLabel(primaryType) : 'Guest'
const displayEmoji = getUserTypeEmoji(primaryType)
return (
<div className="dev-user-switcher" data-expanded={isExpanded}>
{/* Toggle button */}
@ -88,11 +85,11 @@ export default function DevUserSwitcher() {
className="dev-user-switcher-toggle"
onClick={toggleExpanded}
title="Dev User Switcher"
aria-label={`Dev user switcher - current: ${getShortLabel(userType)}`}
aria-label={`Dev user switcher - current: ${displayLabel}`}
aria-expanded={isExpanded}
>
<span className="dev-user-switcher-icon">{getUserTypeIcon(userType)}</span>
<span className="dev-user-switcher-label">{getShortLabel(userType)}</span>
<span className="dev-user-switcher-icon">{displayEmoji}</span>
<span className="dev-user-switcher-label">{displayLabel}</span>
<span className="dev-user-switcher-badge">DEV</span>
</button>
@ -103,33 +100,66 @@ export default function DevUserSwitcher() {
<h3>Dev User Switcher</h3>
<div className="dev-user-switcher-status">
<span className={`dev-user-status-dot ${isAuthenticated ? 'authenticated' : 'guest'}`} />
{isAuthenticated ? 'Authenticated' : 'Guest'}
{isAuthenticated ? `${userTypes.length} type${userTypes.length !== 1 ? 's' : ''}` : 'Guest'}
{hasDeclaredIntent && ' • Intent declared'}
</div>
</div>
<div className="dev-user-switcher-options">
{DEV_USER_TYPES.map((type) => (
<button
key={type}
className={`dev-user-option ${type === userType ? 'active' : ''}`}
onClick={() => {
setUserType(type)
setIsExpanded(false)
}}
role="menuitem"
aria-current={type === userType ? 'true' : undefined}
>
<span className="dev-user-option-icon">{getUserTypeIcon(type)}</span>
<div className="dev-user-option-content">
<span className="dev-user-option-label">{getShortLabel(type)}</span>
<span className="dev-user-option-description">{getDescription(type)}</span>
</div>
{type === userType && (
<span className="dev-user-option-check"></span>
)}
{/* Quick actions */}
<div className="dev-user-switcher-actions">
{isAuthenticated ? (
<button className="dev-user-action dev-user-action--danger" onClick={signOut}>
Sign Out
</button>
))}
) : (
<button className="dev-user-action" onClick={signInAsUser}>
Sign In as User
</button>
)}
</div>
<div className="dev-user-switcher-section-label">
Account Types
<span className="dev-user-switcher-hint">(click to toggle, star to set primary)</span>
</div>
<div className="dev-user-switcher-options">
{DEV_USER_TYPES.map((type) => {
const isActive = hasType(type)
const isPrimary = primaryType === type
return (
<div key={type} className={`dev-user-option ${isActive ? 'active' : ''}`}>
{/* Checkbox area - click to toggle type */}
<button
className="dev-user-option-toggle"
onClick={() => toggleType(type)}
role="menuitemcheckbox"
aria-checked={isActive}
>
<span className={`dev-user-option-checkbox ${isActive ? 'checked' : ''}`}>
{isActive && '✓'}
</span>
<span className="dev-user-option-icon">{getUserTypeEmoji(type)}</span>
<div className="dev-user-option-content">
<span className="dev-user-option-label">{getShortLabel(type)}</span>
<span className="dev-user-option-description">{getDescription(type)}</span>
</div>
</button>
{/* Star button - click to set as primary */}
<button
className={`dev-user-option-primary ${isPrimary ? 'is-primary' : ''}`}
onClick={() => setPrimaryType(type)}
disabled={!canBePrimary(type)}
title={isPrimary ? 'Primary type' : !canBePrimary(type) ? 'Cannot be primary' : 'Set as primary'}
aria-label={isPrimary ? 'Primary type' : `Set ${getShortLabel(type)} as primary`}
>
<Star size={16} fill={isPrimary ? 'currentColor' : 'none'} />
</button>
</div>
)
})}
</div>
<div className="dev-user-switcher-footer">

View file

@ -55,6 +55,41 @@
font-weight: 500;
}
.user-menu__badge {
padding: 2px 6px;
background: rgba(168, 85, 247, 0.3);
border-radius: 10px;
font-size: 11px;
font-weight: 600;
color: #a855f7;
}
/* Multiple types display */
.user-menu__types {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 16px 12px;
}
.user-menu__type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.user-menu__type-badge--primary {
background: rgba(168, 85, 247, 0.15);
border-color: rgba(168, 85, 247, 0.3);
color: #a855f7;
}
.user-menu__chevron {
transition: transform 0.2s ease;
}

View file

@ -42,6 +42,8 @@ interface DevUserContextValue extends DevUserState {
toggleType: (type: DevUserType) => void
/** Check if user has a specific type */
hasType: (type: DevUserType) => boolean
/** Check if a type can be set as primary (User can only be primary if it's the only type) */
canBePrimary: (type: DevUserType) => boolean
/** Sign out - clear all types */
signOut: () => void
/** Sign in as guest (registered-user only) */
@ -163,7 +165,12 @@ export function DevUserProvider({ children }: { children: ReactNode }) {
if (prev.types.includes(type)) return prev
const newTypes = [...prev.types, type]
// If this is the first type, make it primary
const newPrimary = prev.primary || type
// Also, if current primary is 'registered-user' and we're adding a declared type,
// auto-switch primary to the new type (User shouldn't be primary when other types exist)
let newPrimary = prev.primary || type
if (prev.primary === 'registered-user' && type !== 'registered-user') {
newPrimary = type
}
return { types: newTypes, primary: newPrimary }
})
}, [isDevMode])
@ -188,13 +195,19 @@ export function DevUserProvider({ children }: { children: ReactNode }) {
const newTypes = prev.types.filter(t => t !== type)
let newPrimary = prev.primary
if (prev.primary === type) {
newPrimary = newTypes[0] || null
// Pick a new primary, preferring non-User types
const declaredTypes = newTypes.filter(t => t !== 'registered-user')
newPrimary = declaredTypes[0] || newTypes[0] || null
}
return { types: newTypes, primary: newPrimary }
} else {
// Add it
const newTypes = [...prev.types, type]
const newPrimary = prev.primary || type
// Auto-switch from User to the new type if adding a declared type
let newPrimary = prev.primary || type
if (prev.primary === 'registered-user' && type !== 'registered-user') {
newPrimary = type
}
return { types: newTypes, primary: newPrimary }
}
})
@ -208,6 +221,10 @@ export function DevUserProvider({ children }: { children: ReactNode }) {
// Auto-add the type if setting as primary
return { types: [...prev.types, type], primary: type }
}
// User type can only be primary if it's the only type
if (type === 'registered-user' && prev.types.length > 1) {
return prev // Don't allow setting User as primary when other types exist
}
return { ...prev, primary: type }
})
}, [isDevMode])
@ -216,6 +233,16 @@ export function DevUserProvider({ children }: { children: ReactNode }) {
return types.includes(type)
}, [types])
// User type can only be primary if it's the ONLY type selected
const canBePrimary = useCallback((type: DevUserType): boolean => {
if (!types.includes(type)) return false
// registered-user can only be primary if there are no other types
if (type === 'registered-user') {
return types.length === 1
}
return true
}, [types])
const signOut = useCallback(() => {
if (!isDevMode) return
setUserData({ types: [], primary: null })
@ -235,6 +262,7 @@ export function DevUserProvider({ children }: { children: ReactNode }) {
toggleType,
setPrimaryType,
hasType,
canBePrimary,
signOut,
signInAsUser,
isDevMode,

View file

@ -52,45 +52,130 @@
margin: 0 0 1rem 0;
}
/* No Types State */
.profile-no-types {
padding: 1.5rem;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 12px;
text-align: center;
}
.profile-no-types p {
margin: 0;
color: rgba(255, 255, 255, 0.5);
font-size: 0.9375rem;
}
/* Active Types Summary */
.profile-active-types {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.profile-active-type {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: color-mix(in srgb, var(--type-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--type-color) 30%, transparent);
border-radius: 999px;
}
.profile-active-type--primary {
background: color-mix(in srgb, var(--type-color) 15%, transparent);
border-color: var(--type-color);
}
.profile-active-type-emoji {
font-size: 1.25rem;
}
.profile-active-type-label {
font-size: 0.9375rem;
font-weight: 500;
color: #fff;
}
.profile-active-type-badge {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
background: var(--type-color);
border-radius: 999px;
font-size: 0.6875rem;
font-weight: 600;
color: #000;
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* Type Card */
.profile-type-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
}
.profile-type-card:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
border-color: var(--type-color, rgba(255, 255, 255, 0.15));
transform: translateY(-1px);
}
.profile-type-card:disabled {
cursor: default;
}
.profile-type-card--current,
.profile-type-card--active {
background: color-mix(in srgb, var(--type-color) 10%, transparent);
border-color: color-mix(in srgb, var(--type-color) 40%, transparent);
background: color-mix(in srgb, var(--type-color) 8%, transparent);
border-color: color-mix(in srgb, var(--type-color) 30%, transparent);
}
/* Toggle Button (checkbox + icon + info) */
.profile-type-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
padding: 0.75rem;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
}
.profile-type-toggle:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Checkbox */
.profile-type-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
flex-shrink: 0;
transition: all 0.15s ease;
}
.profile-type-checkbox.checked {
background: var(--type-color);
border-color: var(--type-color);
color: #fff;
}
.profile-type-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: color-mix(in srgb, var(--type-color) 15%, transparent);
border-radius: 12px;
width: 44px;
height: 44px;
background: color-mix(in srgb, var(--type-color) 12%, transparent);
border-radius: 10px;
color: var(--type-color);
flex-shrink: 0;
}
@ -102,29 +187,46 @@
.profile-type-label {
display: block;
font-size: 1rem;
font-size: 0.9375rem;
font-weight: 600;
color: #fff;
margin-bottom: 0.25rem;
margin-bottom: 0.125rem;
}
.profile-type-description {
display: block;
font-size: 0.875rem;
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.5);
}
.profile-type-badge {
/* Primary Star Button */
.profile-type-primary {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
background: color-mix(in srgb, var(--type-color) 20%, transparent);
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
color: var(--type-color);
justify-content: center;
width: 40px;
height: 40px;
background: transparent;
border: none;
border-radius: 8px;
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
.profile-type-primary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.6);
}
.profile-type-primary.is-primary {
color: #f59e0b;
}
.profile-type-primary:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Types Grid */

View file

@ -71,6 +71,7 @@ export default function ProfilePage() {
primaryType,
isAuthenticated,
hasType,
canBePrimary,
addType: addUserType,
removeType,
setPrimaryType,
@ -214,8 +215,8 @@ export default function ProfilePage() {
className={`profile-type-primary ${isPrimary ? 'is-primary' : ''}`}
onClick={() => handleSetPrimary(type)}
onMouseEnter={() => playSound('button-hover')}
disabled={!isActive}
title={isPrimary ? 'Primary type' : 'Set as primary'}
disabled={!canBePrimary(type)}
title={isPrimary ? 'Primary type' : !canBePrimary(type) ? 'Cannot be primary when other types exist' : 'Set as primary'}
aria-label={isPrimary ? 'Primary type' : `Set ${info.label} as primary`}
>
<Star size={18} fill={isPrimary ? 'currentColor' : 'none'} />