feat(analytics): ✨ Add funnel analytics controller, health check endpoint, and real-time metrics integration
This commit is contained in:
parent
c6b1c590d1
commit
7c90f98a3c
4 changed files with 59 additions and 58 deletions
|
|
@ -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<RealtimeMetricProps> = ({
|
|||
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<RealtimeMetricProps> = ({
|
|||
<Container>
|
||||
<Label>{label}</Label>
|
||||
<ValueContainer>
|
||||
<Value $animate={animate}>
|
||||
<Value key={value} $animate>
|
||||
{formatValue(value, format)}
|
||||
</Value>
|
||||
{previousValue !== undefined && (
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FormData>(INITIAL_FORM_DATA);
|
||||
|
||||
// Track profile ID to detect when profile changes (React-approved render-time state adjustment)
|
||||
const [prevProfileId, setPrevProfileId] = useState<string | undefined>(existingProfile?.id);
|
||||
const [formData, setFormData] = useState<FormData>(() =>
|
||||
existingProfile ? mapProfileToFormData(existingProfile) : INITIAL_FORM_DATA
|
||||
);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
|
||||
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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue