From b40b3f6fe9a52a6701aad66ebe0d34b2531c93d6 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Sun, 28 Dec 2025 19:46:29 -0800 Subject: [PATCH] feat(landing): multi-type user model with primary selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/DevUserSwitcher.css | 146 +++++++++++++-- .../src/components/DevUserSwitcher.tsx | 124 ++++++++----- .../frontend/src/components/UserMenu.css | 35 ++++ .../frontend/src/contexts/DevUserContext.tsx | 34 +++- .../frontend/src/pages/ProfilePage.css | 168 ++++++++++++++---- .../frontend/src/pages/ProfilePage.tsx | 5 +- 6 files changed, 409 insertions(+), 103 deletions(-) diff --git a/features/landing/frontend/src/components/DevUserSwitcher.css b/features/landing/frontend/src/components/DevUserSwitcher.css index 1f56e4ed8..a31e76310 100644 --- a/features/landing/frontend/src/components/DevUserSwitcher.css +++ b/features/landing/frontend/src/components/DevUserSwitcher.css @@ -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; } } diff --git a/features/landing/frontend/src/components/DevUserSwitcher.tsx b/features/landing/frontend/src/components/DevUserSwitcher.tsx index 5f36831e2..369265e56 100644 --- a/features/landing/frontend/src/components/DevUserSwitcher.tsx +++ b/features/landing/frontend/src/components/DevUserSwitcher.tsx @@ -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 (
{/* 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} > - {getUserTypeIcon(userType)} - {getShortLabel(userType)} + {displayEmoji} + {displayLabel} DEV @@ -103,33 +100,66 @@ export default function DevUserSwitcher() {

Dev User Switcher

- {isAuthenticated ? 'Authenticated' : 'Guest'} + {isAuthenticated ? `${userTypes.length} type${userTypes.length !== 1 ? 's' : ''}` : 'Guest'} {hasDeclaredIntent && ' • Intent declared'}
-
- {DEV_USER_TYPES.map((type) => ( - - ))} + ) : ( + + )} +
+ +
+ Account Types + (click to toggle, star to set primary) +
+ +
+ {DEV_USER_TYPES.map((type) => { + const isActive = hasType(type) + const isPrimary = primaryType === type + + return ( +
+ {/* Checkbox area - click to toggle type */} + + + {/* Star button - click to set as primary */} + +
+ ) + })}
diff --git a/features/landing/frontend/src/components/UserMenu.css b/features/landing/frontend/src/components/UserMenu.css index 07b066f15..97e014d2b 100644 --- a/features/landing/frontend/src/components/UserMenu.css +++ b/features/landing/frontend/src/components/UserMenu.css @@ -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; } diff --git a/features/landing/frontend/src/contexts/DevUserContext.tsx b/features/landing/frontend/src/contexts/DevUserContext.tsx index 84a2bfc47..fb6ce8c00 100644 --- a/features/landing/frontend/src/contexts/DevUserContext.tsx +++ b/features/landing/frontend/src/contexts/DevUserContext.tsx @@ -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, diff --git a/features/landing/frontend/src/pages/ProfilePage.css b/features/landing/frontend/src/pages/ProfilePage.css index 7cbb265a4..fc20be038 100644 --- a/features/landing/frontend/src/pages/ProfilePage.css +++ b/features/landing/frontend/src/pages/ProfilePage.css @@ -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 */ diff --git a/features/landing/frontend/src/pages/ProfilePage.tsx b/features/landing/frontend/src/pages/ProfilePage.tsx index 6b98cb6c9..a73d49654 100644 --- a/features/landing/frontend/src/pages/ProfilePage.tsx +++ b/features/landing/frontend/src/pages/ProfilePage.tsx @@ -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`} >