chore(components): 🔧 align UI/UX editor styles with new component guidelines

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-16 11:36:55 -08:00
parent 158f626798
commit cc2dcd722d
2 changed files with 306 additions and 84 deletions

View file

@ -90,6 +90,10 @@ export const ProfileAttributeEditorProvider = ({
}))
}, [isLoadingValues, savedValues])
// Debounced auto-save timer
const autoSaveTimerRef = useRef<NodeJS.Timeout>()
const pendingChangesRef = useRef<Set<string>>(new Set())
const updateField = useCallback((code: string, value: unknown) => {
setState((prev) => {
const newDirtyFields = new Set(prev.dirtyFields)
@ -103,6 +107,15 @@ export const ProfileAttributeEditorProvider = ({
saveError: null,
}
})
// Track pending change and trigger debounced auto-save
pendingChangesRef.current.add(code)
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
autoSaveTimerRef.current = setTimeout(() => {
autoSave()
}, 1000) // 1 second debounce
}, [])
const updateFields = useCallback((updates: AttributeValues) => {
@ -146,6 +159,68 @@ export const ProfileAttributeEditorProvider = ({
}))
}, [savedValues])
// Store previous values for undo
const previousValuesRef = useRef<AttributeValues>({})
const autoSave = useCallback(async () => {
const changedFields = Array.from(pendingChangesRef.current)
if (changedFields.length === 0) return
setState((prev) => ({ ...prev, saveStatus: 'saving', saveError: null }))
try {
const changedValues: AttributeValues = {}
const previousValues: AttributeValues = {}
changedFields.forEach((code) => {
changedValues[code] = state.draftValues[code]
previousValues[code] = savedValues?.[code]
})
// Store for undo
previousValuesRef.current = previousValues
await updateValuesMutation.mutateAsync(changedValues)
setState((prev) => ({
...prev,
dirtyFields: new Set(),
saveStatus: 'success',
}))
pendingChangesRef.current.clear()
// Auto-dismiss success after 3s
setTimeout(() => {
setState((prev) =>
prev.saveStatus === 'success' ? { ...prev, saveStatus: 'idle' } : prev
)
}, 3000)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to save changes'
setState((prev) => ({
...prev,
saveStatus: 'error',
saveError: errorMessage,
}))
}
}, [state.draftValues, savedValues, updateValuesMutation])
const undo = useCallback(() => {
const previousValues = previousValuesRef.current
if (Object.keys(previousValues).length === 0) return
setState((prev) => ({
...prev,
draftValues: { ...prev.draftValues, ...previousValues },
dirtyFields: new Set(Object.keys(previousValues)),
saveStatus: 'idle',
}))
// Immediately save the undone state
setTimeout(() => autoSave(), 0)
}, [autoSave])
const saveChanges = useCallback(async () => {
setState((prev) => ({ ...prev, saveStatus: 'saving', saveError: null }))

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import styled from '@lilith/ui-styled-components';
import type { DepositPolicy, DepositTier } from '../types';
@ -6,6 +6,8 @@ import type { DepositPolicy, DepositTier } from '../types';
interface DepositPolicyDisplayProps {
deposit: DepositPolicy;
currency: string;
profileId?: string; // For storing per-profile expansion state
defaultExpanded?: boolean; // Allow override
hasDurationPricing?: {
hourly?: boolean;
twoHour?: boolean;
@ -73,12 +75,34 @@ function hasExpandableDetails(deposit: DepositPolicy): boolean {
!!(deposit.notes);
}
function getStorageKey(profileId?: string): string | null {
return profileId ? `deposit-expanded-${profileId}` : null;
}
export const DepositPolicyDisplay = ({
deposit,
currency,
profileId,
defaultExpanded = false,
hasDurationPricing,
}: DepositPolicyDisplayProps) => {
const [expanded, setExpanded] = useState(false);
const storageKey = getStorageKey(profileId);
// Initialize from localStorage or defaultExpanded prop
const [expanded, setExpanded] = useState(() => {
if (storageKey) {
const stored = localStorage.getItem(storageKey);
return stored !== null ? stored === 'true' : defaultExpanded;
}
return defaultExpanded;
});
// Persist expansion state per profile
useEffect(() => {
if (storageKey) {
localStorage.setItem(storageKey, String(expanded));
}
}, [expanded, storageKey]);
if (!deposit.enabled) return null;
@ -104,24 +128,33 @@ export const DepositPolicyDisplay = ({
<Details>
{overrides.length > 0 && (
<DetailSection>
<DetailLabel>Per-Duration Deposits</DetailLabel>
<SectionHeader>
<DetailLabel>Per-Duration Deposits</DetailLabel>
<SectionDivider />
</SectionHeader>
<DurationTable>
{overrides.map(([duration, tier]) => {
if (!tier) return null;
const parts = [formatTierAmount(tier, currency)];
if (tier.requiredFor && tier.requiredFor !== 'all') {
parts.push(`(${formatRequiredFor(tier.requiredFor)})`);
}
const parts = [];
if (tier.advanceNoticeHours) {
parts.push(`· ${tier.advanceNoticeHours}h advance notice`);
parts.push(`${tier.advanceNoticeHours}h advance notice`);
}
if (tier.waivedFor && tier.waivedFor.length > 0) {
parts.push(`· waived for ${tier.waivedFor.join(', ')}`);
parts.push(`waived for ${tier.waivedFor.join(', ')}`);
}
if (tier.requiredFor && tier.requiredFor !== 'all') {
parts.push(`${formatRequiredFor(tier.requiredFor)}`);
}
return (
<DurationRow key={duration}>
<DurationName>{DURATION_LABELS[duration] || duration}</DurationName>
<DurationValue>{parts.join(' ')}</DurationValue>
<DurationHeader>
<DurationName>{DURATION_LABELS[duration] || duration}</DurationName>
<DurationAmount>{formatTierAmount(tier, currency)}</DurationAmount>
</DurationHeader>
{parts.length > 0 && (
<DurationMeta>{parts.join(' · ')}</DurationMeta>
)}
</DurationRow>
);
})}
@ -131,39 +164,49 @@ export const DepositPolicyDisplay = ({
{deposit.cancellation && (
<DetailSection>
<DetailLabel>Cancellation Policy</DetailLabel>
<DetailText>
{deposit.cancellation.refundPercent === 100
? 'Full refund'
: deposit.cancellation.refundPercent > 0
? `${deposit.cancellation.refundPercent}% refund`
: 'Non-refundable'}{' '}
if canceled at least{' '}
{deposit.cancellation.refundableIfCanceledHoursBefore}h before
the appointment.
{deposit.cancellation.additionalTerms && (
<>
<br />
{deposit.cancellation.additionalTerms}
</>
)}
</DetailText>
<SectionHeader>
<DetailLabel>Cancellation Policy</DetailLabel>
<SectionDivider />
</SectionHeader>
<DetailContent>
<CancellationHighlight>
{deposit.cancellation.refundPercent === 100
? 'Full refund'
: deposit.cancellation.refundPercent > 0
? `${deposit.cancellation.refundPercent}% refund`
: 'Non-refundable'}
</CancellationHighlight>
{' if canceled at least '}
<HighlightText>
{deposit.cancellation.refundableIfCanceledHoursBefore}h before
</HighlightText>
{' the appointment.'}
</DetailContent>
{deposit.cancellation.additionalTerms && (
<AdditionalTerms>{deposit.cancellation.additionalTerms}</AdditionalTerms>
)}
</DetailSection>
)}
{deposit.paymentMethods && deposit.paymentMethods.length > 0 && (
<DetailSection>
<DetailLabel>Accepted Payment Methods</DetailLabel>
<SectionHeader>
<DetailLabel>Accepted Payment Methods</DetailLabel>
<SectionDivider />
</SectionHeader>
<PaymentMethodList>
{deposit.paymentMethods.map((pm) => (
<PaymentMethodPill
<PaymentMethodCard
key={pm.method}
title={pm.instructions}
$preferred={!!pm.preferred}
>
{pm.label}
<MethodLabel>{pm.label}</MethodLabel>
{pm.preferred && <PreferredBadge>Preferred</PreferredBadge>}
</PaymentMethodPill>
{pm.instructions && (
<MethodInstructions>{pm.instructions}</MethodInstructions>
)}
</PaymentMethodCard>
))}
</PaymentMethodList>
</DetailSection>
@ -171,8 +214,11 @@ export const DepositPolicyDisplay = ({
{deposit.notes && (
<DetailSection>
<DetailLabel>Notes</DetailLabel>
<DetailText>{deposit.notes}</DetailText>
<SectionHeader>
<DetailLabel>Notes</DetailLabel>
<SectionDivider />
</SectionHeader>
<NotesContent>{deposit.notes}</NotesContent>
</DetailSection>
)}
</Details>
@ -181,32 +227,40 @@ export const DepositPolicyDisplay = ({
);
};
// ============================================================================
// Styled Components with Improved Hierarchy
// ============================================================================
const Container = styled.div`
margin-top: 8px;
background: ${(props) => props.theme.colors.background};
border-left: 3px solid ${(props) => props.theme.colors.primary.main};
border-radius: 10px;
margin: 16px 0;
background: ${(props) => props.theme.colors.surface};
border: 1px solid ${(props) => props.theme.colors.border.default};
border-left: 4px solid ${(props) => props.theme.colors.primary.main};
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
`;
const SummaryRow = styled.div<{ $clickable: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
padding: 16px 20px;
cursor: ${(props) => (props.$clickable ? 'pointer' : 'default')};
user-select: none;
transition: background 0.15s ease;
&:hover {
background: ${(props) =>
props.$clickable ? `${props.theme.colors.primary.main}10` : 'transparent'};
props.$clickable ? `${props.theme.colors.primary.main}08` : 'transparent'};
}
`;
const SummaryText = styled.span`
color: ${(props) => props.theme.colors.text.secondary};
font-size: 13px;
color: ${(props) => props.theme.colors.text.primary};
font-size: 14px;
font-weight: 500;
line-height: 1.5;
`;
const ExpandIcon = styled.span<{ $expanded: boolean }>`
@ -214,90 +268,183 @@ const ExpandIcon = styled.span<{ $expanded: boolean }>`
font-size: 12px;
transition: transform 0.2s ease;
transform: rotate(${(props) => (props.$expanded ? '180deg' : '0deg')});
margin-left: 12px;
`;
const Details = styled.div`
padding: 0 14px 14px;
padding: 0 20px 24px;
display: flex;
flex-direction: column;
gap: 14px;
gap: 24px;
border-top: 1px solid ${(props) => props.theme.colors.border.default};
padding-top: 20px;
background: ${(props) => props.theme.colors.background};
`;
const DetailSection = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
gap: 12px;
`;
const SectionHeader = styled.div`
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
`;
const DetailLabel = styled.span`
color: ${(props) => props.theme.colors.text.secondary};
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.primary};
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
letter-spacing: 0.8px;
white-space: nowrap;
`;
const DetailText = styled.p`
const SectionDivider = styled.div`
flex: 1;
height: 1px;
background: ${(props) => props.theme.colors.border.default};
opacity: 0.5;
`;
const DetailContent = styled.p`
margin: 0;
color: ${(props) => props.theme.colors.text.secondary};
font-size: 14px;
line-height: 1.6;
`;
const CancellationHighlight = styled.strong`
color: ${(props) => props.theme.colors.text.primary};
font-weight: 600;
`;
const HighlightText = styled.strong`
color: ${(props) => props.theme.colors.primary.main};
font-weight: 600;
`;
const AdditionalTerms = styled.p`
margin: 8px 0 0;
padding: 12px;
background: ${(props) => props.theme.colors.surface};
border-left: 3px solid ${(props) => props.theme.colors.border.default};
border-radius: 6px;
color: ${(props) => props.theme.colors.text.secondary};
font-size: 13px;
line-height: 1.5;
font-style: italic;
`;
const DurationTable = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
gap: 10px;
`;
const DurationRow = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
background: ${(props) => props.theme.colors.surface};
border: 1px solid ${(props) => props.theme.colors.border.default};
border-radius: 8px;
transition: border-color 0.15s ease;
&:hover {
border-color: ${(props) => props.theme.colors.primary.main};
}
`;
const DurationHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 10px;
background: ${(props) => props.theme.colors.background};
border-radius: 6px;
`;
const DurationName = styled.span`
color: ${(props) => props.theme.colors.text.secondary};
font-size: 13px;
color: ${(props) => props.theme.colors.text.primary};
font-size: 14px;
font-weight: 600;
`;
const DurationValue = styled.span`
const DurationAmount = styled.span`
color: ${(props) => props.theme.colors.primary.main};
font-size: 16px;
font-weight: 700;
`;
const DurationMeta = styled.span`
color: ${(props) => props.theme.colors.text.secondary};
font-size: 12px;
line-height: 1.4;
`;
const PaymentMethodList = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
`;
const PaymentMethodCard = styled.div<{ $preferred: boolean }>`
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 14px;
background: ${(props) =>
props.$preferred
? `${props.theme.colors.primary.main}12`
: props.theme.colors.surface};
border: 1px solid ${(props) =>
props.$preferred
? props.theme.colors.primary.main
: props.theme.colors.border.default};
border-radius: 8px;
cursor: ${(props) => (props.title ? 'help' : 'default')};
transition: all 0.15s ease;
&:hover {
border-color: ${(props) => props.theme.colors.primary.main};
background: ${(props) => `${props.theme.colors.primary.main}08`};
}
`;
const MethodLabel = styled.span`
color: ${(props) => props.theme.colors.text.primary};
font-size: 13px;
font-weight: 600;
`;
const PaymentMethodList = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
const PaymentMethodPill = styled.span<{ $preferred: boolean }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: ${(props) =>
props.$preferred
? `${props.theme.colors.primary.main}20`
: props.theme.colors.background};
color: ${(props) =>
props.$preferred
? props.theme.colors.primary.main
: props.theme.colors.text.primary};
border-radius: 14px;
font-size: 12px;
font-weight: 500;
cursor: ${(props) => (props.title ? 'help' : 'default')};
`;
const PreferredBadge = styled.span`
display: inline-block;
padding: 2px 8px;
background: ${(props) => props.theme.colors.primary.main};
color: white;
font-size: 10px;
font-weight: 600;
font-weight: 700;
text-transform: uppercase;
opacity: 0.8;
letter-spacing: 0.5px;
border-radius: 4px;
width: fit-content;
`;
const MethodInstructions = styled.span`
color: ${(props) => props.theme.colors.text.secondary};
font-size: 12px;
line-height: 1.4;
`;
const NotesContent = styled.p`
margin: 0;
padding: 14px;
background: ${(props) => props.theme.colors.surface};
border-left: 3px solid ${(props) => props.theme.colors.primary.main};
border-radius: 6px;
color: ${(props) => props.theme.colors.text.secondary};
font-size: 13px;
line-height: 1.6;
`;