chore(components): 🔧 align UI/UX editor styles with new component guidelines
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
158f626798
commit
cc2dcd722d
2 changed files with 306 additions and 84 deletions
|
|
@ -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 }))
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue