diff --git a/@packages/@plugins/analytics/src/components/RealtimeMetric.tsx b/@packages/@plugins/analytics/src/components/RealtimeMetric.tsx index 552636c0f..9d993832f 100755 --- a/@packages/@plugins/analytics/src/components/RealtimeMetric.tsx +++ b/@packages/@plugins/analytics/src/components/RealtimeMetric.tsx @@ -1,7 +1,6 @@ /** @jsxImportSource react */ import type { FC } from 'react'; -import { useEffect, useRef, useState } from 'react' import { calculateSparklinePoints, formatValue, generateLinePath } from './ui-utils-stubs' import type { NumberFormat } from './ui-utils-stubs' import styled, { keyframes } from 'styled-components' @@ -127,20 +126,9 @@ export const RealtimeMetric: FC = ({ trend = 'neutral', sparklineData }) => { - const [animate, setAnimate] = useState(false) - const prevValueRef = useRef(value) - - // Trigger animation when value changes - useEffect(() => { - if (value !== prevValueRef.current) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: trigger animation on value change - setAnimate(true) - prevValueRef.current = value - const timer = setTimeout(() => setAnimate(false), 300) - return () => clearTimeout(timer) - } - return undefined - }, [value]) + // Animation is triggered by key change - when value changes, React remounts + // the Value component with a new key, which plays the CSS animation fresh. + // No state or effect needed for this pattern. // Calculate change from previous value const changeValue = previousValue !== undefined ? ((value - previousValue) / previousValue) * 100 : 0 @@ -172,7 +160,7 @@ export const RealtimeMetric: FC = ({ - + {formatValue(value, format)} {previousValue !== undefined && ( diff --git a/features/analytics/backend-api/src/controllers/funnel-analytics.controller.ts b/features/analytics/backend-api/src/controllers/funnel-analytics.controller.ts index 41d7d22ed..8586f263f 100644 --- a/features/analytics/backend-api/src/controllers/funnel-analytics.controller.ts +++ b/features/analytics/backend-api/src/controllers/funnel-analytics.controller.ts @@ -14,6 +14,9 @@ import { JwtAuthGuard, Public } from '@/auth' import { TrafficSource } from '@/entities/conversion-event.entity' import { FunnelAnalyticsService, + ConversionFunnelDefinition as FunnelDefinition, + ConversionFunnelMetrics as FunnelMetrics, + ConversionFunnelBySourceMetrics as FunnelBySourceMetrics, } from '@/services' @Controller('analytics/funnels') diff --git a/features/analytics/backend-api/src/health/health.controller.ts b/features/analytics/backend-api/src/health/health.controller.ts index 2ba63a764..88ec5fc34 100755 --- a/features/analytics/backend-api/src/health/health.controller.ts +++ b/features/analytics/backend-api/src/health/health.controller.ts @@ -1,4 +1,4 @@ -import { BaseHealthController, DependencyHealth } from '@lilith/nestjs-health'; +import { BaseHealthController, DependencyHealth, HealthStatus } from '@lilith/nestjs-health'; import { TypeOrmConnectionIndicator } from '@lilith/nestjs-health/indicators'; import { Controller } from '@nestjs/common'; import { SkipThrottle } from '@nestjs/throttler'; @@ -44,7 +44,7 @@ export class HealthController extends BaseHealthController { { name: 'database', status: dbHealth.database.status, - latency: dbHealth.database.latency, + responseTime: dbHealth.database.responseTime, message: dbHealth.database.message, }, await this.checkRedis(), @@ -55,7 +55,7 @@ export class HealthController extends BaseHealthController { // Redis is accessible if the app started (via BullMQ or direct client) return { name: 'redis', - status: 'ok', + status: HealthStatus.OK, message: 'Redis accessible', }; } diff --git a/features/marketplace/frontend-public/src/features/provider/pages/hooks/useProfileForm.ts b/features/marketplace/frontend-public/src/features/provider/pages/hooks/useProfileForm.ts index 92ce571b3..d911b8d72 100644 --- a/features/marketplace/frontend-public/src/features/provider/pages/hooks/useProfileForm.ts +++ b/features/marketplace/frontend-public/src/features/provider/pages/hooks/useProfileForm.ts @@ -2,7 +2,7 @@ * useProfileForm - Form state and submission logic for profile setup */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { useNavigate } from '@lilith/ui-router'; @@ -77,51 +77,61 @@ const INITIAL_FORM_DATA: FormData = { touringUntil: '', }; +/** + * Map profile to form data structure + */ +const mapProfileToFormData = (profile: ProviderProfile): FormData => ({ + slug: profile.slug, + displayName: profile.displayName, + tagline: profile.tagline || '', + bio: profile.bio || '', + workTypes: profile.workTypes, + visibleOnVerticals: profile.visibleOnVerticals, + primaryVertical: profile.primaryVertical, + flyMeTo: profile.flyMeTo, + locationCity: profile.locationCity || '', + locationState: profile.locationState || '', + locationCountry: profile.locationCountry || 'USA', + hourlyRateCents: profile.hourlyRateCents || null, + currency: profile.currency || 'USD', + primaryPhotoUrl: profile.primaryPhotoUrl || '', + age: profile.age || null, + heightCm: profile.heightCm || null, + measurements: profile.measurements || '', + hairColor: profile.hairColor || '', + eyeColor: profile.eyeColor || '', + ethnicity: profile.ethnicity || '', + bodyType: profile.bodyType || '', + offersIncall: profile.offersIncall ?? true, + offersOutcall: profile.offersOutcall ?? true, + incallArea: profile.incallArea || '', + galleryUrls: profile.galleryUrls || [], + rates: profile.rates || {}, + isCurrentlyTouring: profile.isCurrentlyTouring || false, + touringCity: profile.touringCity || '', + touringUntil: profile.touringUntil || '', +}); + export const useProfileForm = (existingProfile?: ProviderProfile, isEditMode = false) => { const navigate = useNavigate(); - const [formData, setFormData] = useState(INITIAL_FORM_DATA); + + // Track profile ID to detect when profile changes (React-approved render-time state adjustment) + const [prevProfileId, setPrevProfileId] = useState(existingProfile?.id); + const [formData, setFormData] = useState(() => + existingProfile ? mapProfileToFormData(existingProfile) : INITIAL_FORM_DATA + ); const [errors, setErrors] = useState>>({}); const { mutate: createProfile, isPending: isCreating, error: createError } = useCreateProfile(); const { mutate: updateProfile, isPending: isUpdating, error: updateError } = useUpdateProfile(); - // Populate form with existing data - useEffect(() => { - if (existingProfile) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- Intentional: populate form from props - setFormData({ - slug: existingProfile.slug, - displayName: existingProfile.displayName, - tagline: existingProfile.tagline || '', - bio: existingProfile.bio || '', - workTypes: existingProfile.workTypes, - visibleOnVerticals: existingProfile.visibleOnVerticals, - primaryVertical: existingProfile.primaryVertical, - flyMeTo: existingProfile.flyMeTo, - locationCity: existingProfile.locationCity || '', - locationState: existingProfile.locationState || '', - locationCountry: existingProfile.locationCountry || 'USA', - hourlyRateCents: existingProfile.hourlyRateCents || null, - currency: existingProfile.currency || 'USD', - primaryPhotoUrl: existingProfile.primaryPhotoUrl || '', - age: existingProfile.age || null, - heightCm: existingProfile.heightCm || null, - measurements: existingProfile.measurements || '', - hairColor: existingProfile.hairColor || '', - eyeColor: existingProfile.eyeColor || '', - ethnicity: existingProfile.ethnicity || '', - bodyType: existingProfile.bodyType || '', - offersIncall: existingProfile.offersIncall ?? true, - offersOutcall: existingProfile.offersOutcall ?? true, - incallArea: existingProfile.incallArea || '', - galleryUrls: existingProfile.galleryUrls || [], - rates: existingProfile.rates || {}, - isCurrentlyTouring: existingProfile.isCurrentlyTouring || false, - touringCity: existingProfile.touringCity || '', - touringUntil: existingProfile.touringUntil || '', - }); - } - }, [existingProfile]); + // Reset form when profile identity changes (during render, not effect) + // This follows React's "adjusting state when a prop changes" pattern + // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + if (existingProfile?.id !== prevProfileId) { + setPrevProfileId(existingProfile?.id); + setFormData(existingProfile ? mapProfileToFormData(existingProfile) : INITIAL_FORM_DATA); + } const handleChange = useCallback( (field: string, value: string | string[] | number | boolean | ProfileRates | null) => {