From cc2dcd722d5cf03be48a0b33dfd6d1ed82756e71 Mon Sep 17 00:00:00 2001 From: Lilith Date: Mon, 16 Feb 2026 11:36:55 -0800 Subject: [PATCH] =?UTF-8?q?chore(components):=20=F0=9F=94=A7=20align=20UI/?= =?UTF-8?q?UX=20editor=20styles=20with=20new=20component=20guidelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../ProfileAttributeEditorProvider.tsx | 75 +++++ .../pages/components/DepositPolicyDisplay.tsx | 315 +++++++++++++----- 2 files changed, 306 insertions(+), 84 deletions(-) diff --git a/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/ProfileAttributeEditorProvider.tsx b/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/ProfileAttributeEditorProvider.tsx index fa914ee73..3f5821ea2 100755 --- a/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/ProfileAttributeEditorProvider.tsx +++ b/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/ProfileAttributeEditorProvider.tsx @@ -90,6 +90,10 @@ export const ProfileAttributeEditorProvider = ({ })) }, [isLoadingValues, savedValues]) + // Debounced auto-save timer + const autoSaveTimerRef = useRef() + const pendingChangesRef = useRef>(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({}) + + 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 })) diff --git a/features/profile/frontend-app/src/pages/components/DepositPolicyDisplay.tsx b/features/profile/frontend-app/src/pages/components/DepositPolicyDisplay.tsx index f5a6c122f..a4ef96978 100644 --- a/features/profile/frontend-app/src/pages/components/DepositPolicyDisplay.tsx +++ b/features/profile/frontend-app/src/pages/components/DepositPolicyDisplay.tsx @@ -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 = ({
{overrides.length > 0 && ( - Per-Duration Deposits + + Per-Duration Deposits + + {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 ( - {DURATION_LABELS[duration] || duration} - {parts.join(' ')} + + {DURATION_LABELS[duration] || duration} + {formatTierAmount(tier, currency)} + + {parts.length > 0 && ( + {parts.join(' · ')} + )} ); })} @@ -131,39 +164,49 @@ export const DepositPolicyDisplay = ({ {deposit.cancellation && ( - Cancellation Policy - - {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 && ( - <> -
- {deposit.cancellation.additionalTerms} - - )} -
+ + Cancellation Policy + + + + + {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 && ( + {deposit.cancellation.additionalTerms} + )}
)} {deposit.paymentMethods && deposit.paymentMethods.length > 0 && ( - Accepted Payment Methods + + Accepted Payment Methods + + {deposit.paymentMethods.map((pm) => ( - - {pm.label} + {pm.label} {pm.preferred && Preferred} - + {pm.instructions && ( + {pm.instructions} + )} + ))} @@ -171,8 +214,11 @@ export const DepositPolicyDisplay = ({ {deposit.notes && ( - Notes - {deposit.notes} + + Notes + + + {deposit.notes} )}
@@ -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; `;