- Implemented Prisma schema with models for AdminUser, AppSettings, and Snapshot. - Created seed script to initialize the database with an admin user and JWT secret. - Developed admin login page with form handling and error management. - Added API routes for admin login, logout, change password, and JWT verification. - Integrated Stripe for payment intent management in admin orders. - Established middleware for protecting admin routes with JWT authentication. - Created Zustand stores for managing persona and snapshot states.
208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useRef } from 'react';
|
|
import { configStore, useConfigStore } from '@/store/useConfigStore';
|
|
import { personaStore, usePersonaStore } from '@/store/usePersonaStore';
|
|
import { PricingEngine } from './PricingEngine';
|
|
|
|
export function ConfigPanel() {
|
|
const activeColors = useConfigStore((s) => s.activeColors);
|
|
const activePersona = useConfigStore((s) => s.activePersonaAttire);
|
|
const personas = usePersonaStore((s) => s.personas);
|
|
|
|
const colorsSectionRef = useRef<HTMLElement>(null);
|
|
const personaSectionRef = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
personaStore.getState().hydrate();
|
|
}, []);
|
|
|
|
const handleColorChange = useCallback((key: 'primary' | 'secondary' | 'accent', value: string) => {
|
|
configStore.getState().setColors({ [key]: value });
|
|
}, []);
|
|
|
|
const handlePersonaSelect = useCallback((attire: string) => {
|
|
configStore.getState().setPersonaAttire(attire);
|
|
}, []);
|
|
|
|
const handleReset = useCallback(() => {
|
|
configStore.getState().reset();
|
|
configStore.getState().setHydrated(true);
|
|
}, []);
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
|
{/* --- COLORS SECTION --- */}
|
|
<section
|
|
ref={colorsSectionRef}
|
|
id="section-colors"
|
|
style={{
|
|
borderRadius: '0.5rem',
|
|
padding: '0.75rem',
|
|
}}
|
|
>
|
|
<h3 style={sectionTitleStyle}>Colors</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
<ColorInput label="Primary" value={activeColors.primary} onChange={(v) => handleColorChange('primary', v)} />
|
|
</div>
|
|
</section>
|
|
|
|
{/* --- PERSONA SECTION --- */}
|
|
<section
|
|
ref={personaSectionRef}
|
|
id="section-persona"
|
|
style={{
|
|
borderRadius: '0.5rem',
|
|
padding: '0.75rem',
|
|
}}
|
|
>
|
|
<h3 style={sectionTitleStyle}>Persona Attire</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
{personas.map((persona) => {
|
|
const isActive = activePersona === persona.id;
|
|
return (
|
|
<button
|
|
key={persona.id}
|
|
onClick={() => handlePersonaSelect(persona.id)}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.75rem',
|
|
padding: '0.6rem 0.75rem',
|
|
borderRadius: '0.5rem',
|
|
border: isActive
|
|
? '1px solid rgba(59, 130, 246, 0.5)'
|
|
: '1px solid rgba(0, 0, 0, 0.06)',
|
|
background: isActive
|
|
? 'rgba(59, 130, 246, 0.06)'
|
|
: 'rgba(248, 248, 246, 0.4)',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.25s ease',
|
|
textAlign: 'left',
|
|
width: '100%',
|
|
}}
|
|
aria-pressed={isActive}
|
|
>
|
|
{/* Color preview swatch */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', flexShrink: 0 }}>
|
|
<div style={{
|
|
width: '36px',
|
|
height: '12px',
|
|
borderRadius: '3px 3px 0 0',
|
|
backgroundColor: persona.colors.torso,
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
borderBottom: 'none',
|
|
}} />
|
|
<div style={{
|
|
width: '36px',
|
|
height: '12px',
|
|
borderRadius: '0 0 3px 3px',
|
|
backgroundColor: persona.colors.legs,
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
borderTop: 'none',
|
|
}} />
|
|
</div>
|
|
|
|
{/* Text */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
fontSize: '0.8rem',
|
|
fontWeight: isActive ? 600 : 400,
|
|
color: isActive ? '#374151' : '#94a3b8',
|
|
marginBottom: '2px',
|
|
}}>
|
|
{persona.label}
|
|
</div>
|
|
<div style={{
|
|
fontSize: '0.65rem',
|
|
color: '#64748b',
|
|
lineHeight: 1.3,
|
|
}}>
|
|
{persona.description}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active checkmark */}
|
|
{isActive && (
|
|
<div style={{
|
|
width: '20px',
|
|
height: '20px',
|
|
borderRadius: '50%',
|
|
background: 'rgba(59, 130, 246, 0.2)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
|
|
{/* --- PRICING --- */}
|
|
<PricingEngine />
|
|
|
|
{/* --- RESET --- */}
|
|
<button
|
|
onClick={handleReset}
|
|
style={{
|
|
padding: '0.6rem',
|
|
borderRadius: '0.375rem',
|
|
border: '1px solid rgba(239, 68, 68, 0.2)',
|
|
background: 'rgba(239, 68, 68, 0.05)',
|
|
color: '#ef4444',
|
|
cursor: 'pointer',
|
|
fontSize: '0.8rem',
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
>
|
|
Reset Configuration
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ColorInput({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
<input
|
|
type="color"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
style={{
|
|
width: '32px',
|
|
height: '32px',
|
|
border: '2px solid rgba(0, 0, 0, 0.08)',
|
|
borderRadius: '0.375rem',
|
|
background: 'transparent',
|
|
cursor: 'pointer',
|
|
padding: 0,
|
|
}}
|
|
aria-label={`${label} color`}
|
|
/>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: '0.8rem', color: '#374151', marginBottom: '2px' }}>{label}</div>
|
|
<div style={{ fontSize: '0.7rem', color: '#64748b', fontFamily: 'monospace' }}>{value}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const sectionTitleStyle: React.CSSProperties = {
|
|
fontSize: '0.75rem',
|
|
fontWeight: 500,
|
|
color: '#94a3b8',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.05em',
|
|
margin: 0,
|
|
paddingBottom: '0.5rem',
|
|
borderBottom: '1px solid rgba(0, 0, 0, 0.06)',
|
|
marginBottom: '0.75rem',
|
|
};
|