add ab test

This commit is contained in:
dev.daminik00 2025-10-21 22:07:55 +02:00
parent 2e1bca8466
commit d47bedc427
15 changed files with 714 additions and 289 deletions

View File

@ -4,6 +4,7 @@
"title": "Soulmate V1",
"description": "Soulmate",
"firstScreenId": "onboarding",
"googleAnalyticsId": "G-4N17LL3BB5",
"yandexMetrikaId": "104471567"
},
"defaultTexts": {
@ -46,7 +47,62 @@
"align": "center",
"color": "default"
},
"variants": [],
"variants": [
{
"conditions": [
{
"screenId": "onboarding",
"operator": "includesAny",
"conditionType": "unleash",
"unleashFlag": "soulmate-onboarding-image",
"unleashVariants": [
"v0"
]
}
],
"overrides": {}
},
{
"conditions": [
{
"screenId": "onboarding",
"operator": "includesAny",
"conditionType": "unleash",
"unleashFlag": "soulmate-onboarding-image",
"unleashVariants": [
"v1"
]
}
],
"overrides": {
"soulmatePortraitsDelivered": {
"image": null,
"mediaUrl": "/images/90b8c77f-c0cd-475d-a4de-bcabb3708c59.png",
"mediaType": "image"
}
}
},
{
"conditions": [
{
"screenId": "onboarding",
"operator": "includesAny",
"conditionType": "unleash",
"unleashFlag": "soulmate-onboarding-image",
"unleashVariants": [
"v2"
]
}
],
"overrides": {
"soulmatePortraitsDelivered": {
"image": null,
"mediaUrl": "/images/275472b0-30e0-47d7-a1ab-8090bc9fb236.mp4",
"mediaType": "video"
}
}
}
],
"soulmatePortraitsDelivered": {
"image": "/soulmate-portrait-delivered-male.jpg",
"text": {
@ -123,40 +179,7 @@
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [
{
"conditions": [
{
"screenId": "gender",
"conditionType": "unleash",
"operator": "includesAny",
"optionIds": [],
"values": [],
"unleashFlag": "soulmate-gender-info",
"unleashVariants": [
"v0"
]
}
],
"nextScreenId": "partner-gender"
},
{
"conditions": [
{
"screenId": "gender",
"conditionType": "unleash",
"operator": "includesAny",
"optionIds": [],
"values": [],
"unleashFlag": "soulmate-gender-info",
"unleashVariants": [
"v1"
]
}
],
"nextScreenId": "gender-info"
}
],
"rules": [],
"defaultNextScreenId": "partner-gender",
"isEndScreen": false
},
@ -178,81 +201,7 @@
],
"registrationFieldKey": "profile.gender"
},
"variants": [
{
"conditions": [
{
"screenId": "onboarding",
"operator": "includesAny",
"conditionType": "unleash",
"unleashFlag": "soulmate-onboarding-image",
"unleashVariants": [
"v2"
]
}
],
"overrides": {
"title": {
"text": "Whats your gender? v2"
}
}
}
]
},
{
"id": "gender-info",
"template": "info",
"header": {
"showBackButton": true,
"show": true
},
"title": {
"text": "Over 120,000 women have already taken this step to get the most accurate results.",
"show": true,
"font": "manrope",
"weight": "bold",
"size": "2xl",
"align": "center",
"color": "default"
},
"subtitle": {
"text": "…and gained a deep portrait of their partner.",
"show": true,
"font": "manrope",
"weight": "medium",
"size": "lg",
"align": "center",
"color": "default"
},
"bottomActionButton": {
"show": true,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [],
"defaultNextScreenId": "partner-gender",
"isEndScreen": false
},
"variables": [],
"variants": [
{
"conditions": [
{
"screenId": "gender",
"operator": "includesAny",
"optionIds": [
"male"
]
}
],
"overrides": {
"title": {
"text": "Over 80,000 men have already taken this step to get the most accurate results."
}
}
}
]
"variants": []
},
{
"id": "partner-gender",

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -145,6 +145,14 @@ async function downloadImagesFromDatabase(funnels) {
imageUrls.add(screen.image.src);
}
// Проверяем soulmatePortraitsDelivered (soulmate экраны)
if (screen.soulmatePortraitsDelivered?.image?.startsWith('/api/images/')) {
imageUrls.add(screen.soulmatePortraitsDelivered.image);
}
if (screen.soulmatePortraitsDelivered?.mediaUrl?.startsWith('/api/images/')) {
imageUrls.add(screen.soulmatePortraitsDelivered.mediaUrl);
}
// Проверяем icon и image в вариантах экрана
if (screen.variants && Array.isArray(screen.variants)) {
for (const variant of screen.variants) {
@ -157,6 +165,13 @@ async function downloadImagesFromDatabase(funnels) {
if (variant.overrides?.image?.src?.startsWith('/api/images/')) {
imageUrls.add(variant.overrides.image.src);
}
// soulmatePortraitsDelivered в вариантах (soulmate экраны)
if (variant.overrides?.soulmatePortraitsDelivered?.image?.startsWith('/api/images/')) {
imageUrls.add(variant.overrides.soulmatePortraitsDelivered.image);
}
if (variant.overrides?.soulmatePortraitsDelivered?.mediaUrl?.startsWith('/api/images/')) {
imageUrls.add(variant.overrides.soulmatePortraitsDelivered.mediaUrl);
}
}
}
}
@ -234,6 +249,20 @@ function updateImageUrlsInFunnels(funnels, imageMapping) {
console.log(`🔗 Updated image URL: ${oldUrl}${newUrl}`);
}
// Обновляем soulmatePortraitsDelivered (soulmate экраны)
if (screen.soulmatePortraitsDelivered?.image && imageMapping[screen.soulmatePortraitsDelivered.image]) {
const oldUrl = screen.soulmatePortraitsDelivered.image;
const newUrl = imageMapping[oldUrl];
screen.soulmatePortraitsDelivered.image = newUrl;
console.log(`🔗 Updated soulmate image URL: ${oldUrl}${newUrl}`);
}
if (screen.soulmatePortraitsDelivered?.mediaUrl && imageMapping[screen.soulmatePortraitsDelivered.mediaUrl]) {
const oldUrl = screen.soulmatePortraitsDelivered.mediaUrl;
const newUrl = imageMapping[oldUrl];
screen.soulmatePortraitsDelivered.mediaUrl = newUrl;
console.log(`🔗 Updated soulmate mediaUrl: ${oldUrl}${newUrl}`);
}
// Обновляем icon и image в вариантах экрана
if (screen.variants && Array.isArray(screen.variants)) {
for (const variant of screen.variants) {
@ -252,6 +281,19 @@ function updateImageUrlsInFunnels(funnels, imageMapping) {
variant.overrides.image.src = newUrl;
console.log(`🔗 Updated variant image URL: ${oldUrl}${newUrl}`);
}
// soulmatePortraitsDelivered в вариантах (soulmate экраны)
if (variant.overrides?.soulmatePortraitsDelivered?.image && imageMapping[variant.overrides.soulmatePortraitsDelivered.image]) {
const oldUrl = variant.overrides.soulmatePortraitsDelivered.image;
const newUrl = imageMapping[oldUrl];
variant.overrides.soulmatePortraitsDelivered.image = newUrl;
console.log(`🔗 Updated variant soulmate image URL: ${oldUrl}${newUrl}`);
}
if (variant.overrides?.soulmatePortraitsDelivered?.mediaUrl && imageMapping[variant.overrides.soulmatePortraitsDelivered.mediaUrl]) {
const oldUrl = variant.overrides.soulmatePortraitsDelivered.mediaUrl;
const newUrl = imageMapping[oldUrl];
variant.overrides.soulmatePortraitsDelivered.mediaUrl = newUrl;
console.log(`🔗 Updated variant soulmate mediaUrl: ${oldUrl}${newUrl}`);
}
}
}
}

View File

@ -4,8 +4,20 @@ import { Image } from '@/lib/models/Image';
import { IS_FRONTEND_ONLY_BUILD } from '@/lib/runtime/buildVariant';
import crypto from 'crypto';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB (увеличено для видео)
const ALLOWED_TYPES = [
// Изображения
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
// Видео
'video/mp4',
'video/quicktime', // .mov файлы
'video/webm'
];
export async function POST(request: NextRequest) {
try {
@ -34,14 +46,14 @@ export async function POST(request: NextRequest) {
// Валидация файла
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 5MB' },
{ error: 'File too large. Maximum size is 10MB' },
{ status: 400 }
);
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only images are allowed' },
{ error: 'Invalid file type. Allowed: images (JPG, PNG, WebP, GIF) and videos (MP4, MOV, WebM)' },
{ status: 400 }
);
}

View File

@ -0,0 +1,381 @@
"use client";
import { useState, useRef, useCallback } from 'react';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import { TextInput } from '@/components/ui/TextInput/TextInput';
import { env } from '@/lib/env';
import { BUILD_VARIANTS } from '@/lib/constants';
import { Upload, X, ImageIcon, Film, Loader2 } from 'lucide-react';
interface UploadedMedia {
id: string;
filename: string;
originalName: string;
url: string;
size: number;
mimetype: string;
}
type MediaType = "image" | "video" | "gif";
interface MediaUploadProps {
currentMediaUrl?: string;
currentMediaType?: MediaType;
onMediaSelect: (url: string, type: MediaType) => void;
onMediaRemove: () => void;
funnelId?: string;
}
export function MediaUpload({
currentMediaUrl,
currentMediaType = "image",
onMediaSelect,
onMediaRemove,
funnelId
}: MediaUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uploadedMedia, setUploadedMedia] = useState<UploadedMedia[]>([]);
const [showGallery, setShowGallery] = useState(false);
const [isLoadingGallery, setIsLoadingGallery] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const getMediaType = (mimetype: string): MediaType => {
if (mimetype.includes('gif')) return 'gif';
if (mimetype.includes('video')) return 'video';
return 'image';
};
const loadMedia = useCallback(async () => {
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
return;
}
setIsLoadingGallery(true);
try {
const params = new URLSearchParams();
if (funnelId) params.append('funnelId', funnelId);
params.append('limit', '20');
const response = await fetch(`/api/images?${params}`);
if (response.ok) {
const data = await response.json();
setUploadedMedia(data.images);
} else {
console.error('Failed to load media');
}
} catch (error) {
console.error('Error loading media:', error);
} finally {
setIsLoadingGallery(false);
}
}, [funnelId]);
const handleFileUpload = async (file: File) => {
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
setError('Загрузка файлов недоступна в frontend режиме');
return;
}
setIsUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append('file', file);
if (funnelId) formData.append('funnelId', funnelId);
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData,
});
if (response.ok) {
const uploadedFile = await response.json();
const mediaType = getMediaType(uploadedFile.mimetype);
onMediaSelect(uploadedFile.url, mediaType);
setUploadedMedia(prev => [uploadedFile, ...prev]);
} else {
const errorData = await response.json();
setError(errorData.error || 'Ошибка загрузки файла');
}
} catch (error) {
setError('Произошла ошибка при загрузке файла');
console.error('Upload error:', error);
} finally {
setIsUploading(false);
}
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file && (file.type.startsWith('image/') || file.type.startsWith('video/'))) {
handleFileUpload(file);
} else {
setError('Пожалуйста, выберите изображение или видео файл');
}
};
const openGallery = () => {
setShowGallery(true);
loadMedia();
};
const isFullMode = env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT !== BUILD_VARIANTS.FRONTEND;
const getMediaIcon = () => {
if (currentMediaType === 'video' || currentMediaType === 'gif') {
return <Film className="h-4 w-4 text-muted-foreground" />;
}
return <ImageIcon className="h-4 w-4 text-muted-foreground" />;
};
const getMediaLabel = () => {
if (currentMediaType === 'video') return 'Загруженное видео';
if (currentMediaType === 'gif') return 'Загруженный GIF';
return 'Загруженное изображение';
};
return (
<div className="space-y-3">
{/* Текущий медиа файл */}
{currentMediaUrl && (
<div className="relative">
<div className="rounded-lg border border-border p-2 bg-muted/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getMediaIcon()}
<span className="text-sm text-muted-foreground truncate">
{currentMediaUrl.startsWith('/api/images/') ? getMediaLabel() : currentMediaUrl}
</span>
</div>
<Button
variant="ghost"
onClick={onMediaRemove}
className="h-6 w-6 p-0 text-destructive hover:bg-destructive/10"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* Превью */}
<div className="mt-2 rounded-lg border border-border overflow-hidden bg-black">
{currentMediaType === 'image' ? (
<Image
src={currentMediaUrl}
alt="Preview"
width={200}
height={120}
className="object-cover w-full h-auto max-h-[120px]"
unoptimized
/>
) : (
<video
src={currentMediaUrl}
autoPlay
loop
muted
playsInline
className="w-full h-auto max-h-[120px] object-cover"
/>
)}
</div>
</div>
)}
{!currentMediaUrl && (
<div className="space-y-3">
{/* URL Input */}
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Ссылка на медиа файл
</label>
<TextInput
placeholder="https://example.com/video.mp4 или image.jpg"
value=""
onChange={(e) => {
const url = e.target.value;
// Определяем тип по расширению
let type: MediaType = "image";
if (url.match(/\.(mp4|mov|webm)$/i)) type = "video";
else if (url.match(/\.gif$/i)) type = "gif";
onMediaSelect(url, type);
}}
/>
<p className="text-xs text-muted-foreground mt-1">
Поддерживаются: изображения (JPG, PNG, WebP), видео (MP4, MOV, WebM), GIF
</p>
</div>
{/* Upload Section */}
{isFullMode && (
<>
<div className="text-center text-sm text-muted-foreground">или</div>
{/* Drag & Drop Zone */}
<div
className={`
relative border-2 border-dashed rounded-lg p-6 text-center transition-colors
${dragActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}
${isUploading ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,.gif"
onChange={handleFileSelect}
className="hidden"
/>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Загрузка...</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<div className="flex gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<Film className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Перетащите медиа файл сюда или нажмите для выбора
</p>
<p className="text-xs text-muted-foreground">
Изображения, видео (MP4, MOV, WebM), GIF. Максимум 10MB
</p>
</div>
)}
</div>
{/* Gallery Button */}
<Button
variant="outline"
onClick={openGallery}
disabled={isUploading}
className="w-full"
>
<ImageIcon className="h-4 w-4 mr-2" />
Выбрать из загруженных
</Button>
</>
)}
</div>
)}
{/* Error Message */}
{error && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded p-2">
{error}
</div>
)}
{/* Gallery Modal */}
{showGallery && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-lg p-6 max-w-2xl max-h-[80vh] overflow-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Выберите медиа файл</h3>
<Button
variant="ghost"
onClick={() => setShowGallery(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{isLoadingGallery ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : uploadedMedia.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{uploadedMedia.map((media, index) => {
const mediaType = getMediaType(media.mimetype);
return (
<div
key={media.filename || `media-${index}`}
className="relative aspect-square border border-border rounded cursor-pointer hover:bg-muted/50 overflow-hidden"
onClick={() => {
onMediaSelect(media.url, mediaType);
setShowGallery(false);
}}
>
{mediaType === 'image' ? (
<Image
src={media.url}
alt={media.originalName}
fill
className="object-cover"
unoptimized={media.url.startsWith('/api/images/')}
/>
) : (
<video
src={media.url}
muted
loop
playsInline
className="w-full h-full object-cover"
onMouseEnter={(e) => e.currentTarget.play()}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
)}
<div className="absolute top-1 right-1 bg-black/75 text-white text-xs px-2 py-0.5 rounded z-10">
{mediaType === 'video' && '🎬'}
{mediaType === 'gif' && '🎞️'}
{mediaType === 'image' && '🖼️'}
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/75 text-white text-xs p-1 truncate z-10">
{media.originalName}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
Пока нет загруженных файлов
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -3,7 +3,7 @@
import React from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Button } from "@/components/ui/button";
import { ImageUpload } from "@/components/admin/builder/forms/ImageUpload";
import { MediaUpload } from "@/components/admin/builder/forms/MediaUpload";
import { Plus, Trash2 } from "lucide-react";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
@ -31,11 +31,13 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
updates: Partial<NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>>
) => {
const base = screen.soulmatePortraitsDelivered ?? {};
const nextDelivered = { ...base, ...updates };
// Важно для вариантов: сохраняем undefined значения!
// Система вариантов использует undefined для отслеживания удаленных полей
onUpdate({
soulmatePortraitsDelivered: {
...base,
...updates,
},
soulmatePortraitsDelivered: nextDelivered as NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>,
});
};
@ -101,13 +103,17 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
<h4 className="text-sm font-medium text-slate-700 mb-3">Блок доставленных портретов</h4>
<div className="space-y-3">
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение</span>
<ImageUpload
currentValue={screen.soulmatePortraitsDelivered?.image}
onImageSelect={(url) => updateDelivered({ image: url })}
onImageRemove={() => updateDelivered({ image: undefined })}
<span className="text-xs font-medium text-muted-foreground mb-2 block">Основное медиа (изображение/видео/GIF)</span>
<MediaUpload
currentMediaUrl={screen.soulmatePortraitsDelivered?.mediaUrl || screen.soulmatePortraitsDelivered?.image}
currentMediaType={screen.soulmatePortraitsDelivered?.mediaType}
onMediaSelect={(url, type) => updateDelivered({ mediaUrl: url, mediaType: type, image: null as unknown as undefined })}
onMediaRemove={() => updateDelivered({ mediaUrl: null as unknown as undefined, mediaType: null as unknown as undefined, image: null as unknown as undefined })}
funnelId={screen.id}
/>
<p className="text-xs text-muted-foreground mt-1">
Поддержка: изображения (JPG, PNG, WebP), видео (MP4, MOV), GIF с автовоспроизведением без звука
</p>
</div>
<TextInput
label="Текст под изображением"
@ -138,11 +144,12 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
<div className="space-y-3">
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение</span>
<ImageUpload
currentValue={avatar.src}
onImageSelect={(url) => updateAvatar(index, { src: url })}
onImageRemove={() => updateAvatar(index, { src: "" })}
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение (только фото, не видео)</span>
<MediaUpload
currentMediaUrl={avatar.src}
currentMediaType="image"
onMediaSelect={(url) => updateAvatar(index, { src: url })}
onMediaRemove={() => updateAvatar(index, { src: "" })}
funnelId={screen.id}
/>
</div>

View File

@ -0,0 +1,24 @@
"use client";
import { useVariant } from "@unleash/proxy-client-react";
import { useEffect } from "react";
interface FlagVariantFetcherProps {
flag: string;
onVariantLoaded: (flag: string, variant: string | undefined) => void;
}
/**
* Компонент для получения варианта одного флага
* Каждый экземпляр этого компонента вызывает useVariant на верхнем уровне
* Это позволяет обходить ограничение правил хуков React
*/
export function FlagVariantFetcher({ flag, onVariantLoaded }: FlagVariantFetcherProps) {
const variant = useVariant(flag);
useEffect(() => {
onVariantLoaded(flag, variant?.name);
}, [flag, variant?.name, onVariantLoaded]);
return null; // Этот компонент не рендерит UI
}

View File

@ -1,9 +1,10 @@
"use client";
import { useMemo, type ReactNode } from "react";
import { useFlagsStatus, useVariant } from "@unleash/proxy-client-react";
import { useState, useMemo, useCallback, type ReactNode } from "react";
import { useFlagsStatus } from "@unleash/proxy-client-react";
import { UnleashContextProvider } from "@/lib/funnel/unleash";
import { FunnelLoadingScreen } from "./FunnelLoadingScreen";
import { FlagVariantFetcher } from "./FlagVariantFetcher";
import type { NavigationConditionDefinition } from "@/lib/funnel/types";
interface FunnelUnleashWrapperProps {
@ -66,15 +67,24 @@ export function FunnelUnleashWrapper({
return Array.from(flags);
}, [funnel.screens]);
// Получаем варианты для всех флагов
const flagVariants = allFlags.map((flag) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const variant = useVariant(flag);
return {
flag,
variant: variant?.name,
};
});
// Состояние для хранения вариантов флагов
const [loadedVariants, setLoadedVariants] = useState<Record<string, string>>({});
// Колбэк для получения варианта от FlagVariantFetcher компонента
const handleVariantLoaded = useCallback((flag: string, variant: string | undefined) => {
if (variant && variant !== "disabled") {
setLoadedVariants((prev) => {
// Обновляем только если значение изменилось
if (prev[flag] !== variant) {
if (process.env.NODE_ENV === "development") {
console.log(`[FunnelUnleashWrapper] Flag "${flag}" = "${variant}"`);
}
return { ...prev, [flag]: variant };
}
return prev;
});
}
}, []);
// Создаем объект активных вариантов
const activeVariants = useMemo(() => {
@ -82,15 +92,12 @@ export function FunnelUnleashWrapper({
return {};
}
const variants: Record<string, string> = {};
flagVariants.forEach(({ flag, variant }) => {
if (variant && variant !== "disabled") {
variants[flag] = variant;
}
});
if (process.env.NODE_ENV === "development") {
console.log("[FunnelUnleashWrapper] Active variants:", loadedVariants);
}
return variants;
}, [flagsReady, flagVariants]);
return loadedVariants;
}, [flagsReady, loadedVariants]);
// Показываем loader пока флаги загружаются
// Это предотвращает flash of unstyled content
@ -100,6 +107,14 @@ export function FunnelUnleashWrapper({
return (
<UnleashContextProvider activeVariants={activeVariants}>
{/* Рендерим FlagVariantFetcher для каждого флага */}
{allFlags.map((flag) => (
<FlagVariantFetcher
key={flag}
flag={flag}
onVariantLoaded={handleVariantLoaded}
/>
))}
{children}
</UnleashContextProvider>
);

View File

@ -62,6 +62,8 @@ export function SoulmatePortraitTemplate({
{screen.soulmatePortraitsDelivered && (
<SoulmatePortraitsDelivered
image={screen.soulmatePortraitsDelivered.image}
mediaType={screen.soulmatePortraitsDelivered.mediaType}
mediaUrl={screen.soulmatePortraitsDelivered.mediaUrl}
textProps={
screen.soulmatePortraitsDelivered.text
? buildTypographyProps(screen.soulmatePortraitsDelivered.text, {

View File

@ -4,35 +4,62 @@ import Typography, {
TypographyProps,
} from "@/components/ui/Typography/Typography";
type MediaType = "image" | "video" | "gif";
interface SoulmatePortraitsDeliveredProps extends React.ComponentProps<"div"> {
image?: string;
image?: string; // Legacy: используется как background если нет mediaUrl
mediaType?: MediaType;
mediaUrl?: string; // Приоритет над image
textProps?: TypographyProps<"p">;
avatarsProps?: React.ComponentProps<typeof Avatars>;
}
export default function SoulmatePortraitsDelivered({
image,
mediaType = "image",
mediaUrl,
avatarsProps,
textProps,
...props
}: SoulmatePortraitsDeliveredProps) {
// Определяем финальный URL медиа (приоритет у mediaUrl)
const finalMediaUrl = mediaUrl || image;
const finalMediaType = mediaUrl ? mediaType : "image";
const shouldRenderVideo = finalMediaType === "video" || finalMediaType === "gif";
return (
<div
style={{
backgroundImage: `url(${image})`,
}}
{...props}
className={cn(
"flex flex-col items-center justify-end",
"relative flex flex-col items-center justify-end",
"w-full max-w-[336px] h-[220px]",
"p-5",
"rounded-3xl",
"rounded-3xl overflow-hidden",
"shadow-soulmate-portrait",
"bg-cover bg-top bg-no-repeat",
props.className
)}
>
<div className={cn("flex gap-[9px]")}>
{/* Background Media */}
{shouldRenderVideo && finalMediaUrl ? (
<video
src={finalMediaUrl}
autoPlay
loop
muted
playsInline
className="absolute inset-0 w-full h-full object-cover"
/>
) : finalMediaUrl ? (
<div
className="absolute inset-0 w-full h-full bg-cover bg-top bg-no-repeat"
style={{
backgroundImage: `url(${finalMediaUrl})`,
}}
/>
) : null}
{/* Content Overlay */}
<div className={cn("relative z-10 flex gap-[9px]")}>
{avatarsProps && <Avatars {...avatarsProps} />}
{textProps && (
<Typography

View File

@ -12,6 +12,7 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"title": "Soulmate V1",
"description": "Soulmate",
"firstScreenId": "onboarding",
"googleAnalyticsId": "G-4N17LL3BB5",
"yandexMetrikaId": "104471567"
},
"defaultTexts": {
@ -54,7 +55,62 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"align": "center",
"color": "default"
},
"variants": [],
"variants": [
{
"conditions": [
{
"screenId": "onboarding",
"operator": "includesAny",
"conditionType": "unleash",
"unleashFlag": "soulmate-onboarding-image",
"unleashVariants": [
"v0"
]
}
],
"overrides": {}
},
{
"conditions": [
{
"screenId": "onboarding",
"operator": "includesAny",
"conditionType": "unleash",
"unleashFlag": "soulmate-onboarding-image",
"unleashVariants": [
"v1"
]
}
],
"overrides": {
"soulmatePortraitsDelivered": {
"image": null,
"mediaUrl": "/images/90b8c77f-c0cd-475d-a4de-bcabb3708c59.png",
"mediaType": "image"
}
}
},
{
"conditions": [
{
"screenId": "onboarding",
"operator": "includesAny",
"conditionType": "unleash",
"unleashFlag": "soulmate-onboarding-image",
"unleashVariants": [
"v2"
]
}
],
"overrides": {
"soulmatePortraitsDelivered": {
"image": null,
"mediaUrl": "/images/275472b0-30e0-47d7-a1ab-8090bc9fb236.mp4",
"mediaType": "video"
}
}
}
],
"soulmatePortraitsDelivered": {
"image": "/soulmate-portrait-delivered-male.jpg",
"text": {
@ -131,40 +187,7 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [
{
"conditions": [
{
"screenId": "gender",
"conditionType": "unleash",
"operator": "includesAny",
"optionIds": [],
"values": [],
"unleashFlag": "soulmate-gender-info",
"unleashVariants": [
"v0"
]
}
],
"nextScreenId": "partner-gender"
},
{
"conditions": [
{
"screenId": "gender",
"conditionType": "unleash",
"operator": "includesAny",
"optionIds": [],
"values": [],
"unleashFlag": "soulmate-gender-info",
"unleashVariants": [
"v1"
]
}
],
"nextScreenId": "gender-info"
}
],
"rules": [],
"defaultNextScreenId": "partner-gender",
"isEndScreen": false
},
@ -186,81 +209,7 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
],
"registrationFieldKey": "profile.gender"
},
"variants": [
{
"conditions": [
{
"screenId": "onboarding",
"operator": "includesAny",
"conditionType": "unleash",
"unleashFlag": "soulmate-onboarding-image",
"unleashVariants": [
"v2"
]
}
],
"overrides": {
"title": {
"text": "Whats your gender? v2"
}
}
}
]
},
{
"id": "gender-info",
"template": "info",
"header": {
"showBackButton": true,
"show": true
},
"title": {
"text": "Over 120,000 women have already taken this step to get the most accurate results.",
"show": true,
"font": "manrope",
"weight": "bold",
"size": "2xl",
"align": "center",
"color": "default"
},
"subtitle": {
"text": "…and gained a deep portrait of their partner.",
"show": true,
"font": "manrope",
"weight": "medium",
"size": "lg",
"align": "center",
"color": "default"
},
"bottomActionButton": {
"show": true,
"cornerRadius": "3xl",
"showPrivacyTermsConsent": false
},
"navigation": {
"rules": [],
"defaultNextScreenId": "partner-gender",
"isEndScreen": false
},
"variables": [],
"variants": [
{
"conditions": [
{
"screenId": "gender",
"operator": "includesAny",
"optionIds": [
"male"
]
}
],
"overrides": {
"title": {
"text": "Over 80,000 men have already taken this step to get the most accurate results."
}
}
}
]
"variants": []
},
{
"id": "partner-gender",

View File

@ -125,11 +125,12 @@ export interface NavigationDefinition {
}
// Рекурсивный Partial для глубоких вложенных объектов
// Поддерживает null для явного удаления полей в вариантах экранов
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
[P in keyof T]?: DeepPartial<T[P]> | null;
}
: T;
: T | null;
// Варианты могут переопределять любые поля экрана, включая вложенные объекты
type ScreenVariantOverrides<T> = DeepPartial<
@ -359,7 +360,9 @@ export interface SoulmatePortraitScreenDefinition {
subtitle?: SubtitleDefinition;
description?: TypographyVariant; // Настраиваемый текст описания
soulmatePortraitsDelivered?: {
image?: string;
image?: string; // Legacy support - используется как background если нет mediaUrl
mediaType?: "image" | "video" | "gif"; // Тип медиа контента
mediaUrl?: string; // URL видео/gif файла (приоритет над image)
text?: TypographyVariant;
avatars?: Array<{
src: string;

View File

@ -10,13 +10,23 @@
* @param variant - вариант который получил пользователь
*/
export function sendUnleashImpression(flag: string, variant: string | undefined) {
if (process.env.NODE_ENV === "development") {
console.log("[Unleash Impression] Called with:", { flag, variant });
}
// Проверяем что вариант валидный
if (!variant || variant === "disabled") {
if (process.env.NODE_ENV === "development") {
console.log("[Unleash Impression] Skipped: invalid variant");
}
return;
}
// Проверяем что браузерное окружение
if (typeof window === "undefined") {
if (process.env.NODE_ENV === "development") {
console.log("[Unleash Impression] Skipped: not browser environment");
}
return;
}
@ -35,26 +45,29 @@ export function sendUnleashImpression(flag: string, variant: string | undefined)
return;
}
// Проверяем наличие gtag
if (!window.gtag) {
console.warn("[Unleash Impression] ❌ Google Analytics not available (window.gtag is undefined)");
console.warn("[Unleash Impression] Check that GoogleAnalytics component is loaded with measurementId");
return;
}
// Отправляем событие в Google Analytics
if (window.gtag) {
window.gtag("event", "experiment_impression", {
app_name: "witlab-funnel",
window.gtag("event", "experiment_impression", {
app_name: "witlab-funnel",
feature: flag,
treatment: variant,
});
// Помечаем что отправили
sessionStorage.setItem(storageKey, "true");
// Debug в development
if (process.env.NODE_ENV === "development") {
console.log("[Unleash Impression] ✅ Sent successfully:", {
feature: flag,
treatment: variant,
variant: variant,
});
// Помечаем что отправили
sessionStorage.setItem(storageKey, "true");
// Debug в development
if (process.env.NODE_ENV === "development") {
console.log("[Unleash Impression] Sent:", {
feature: flag,
variant: variant,
});
}
} else if (process.env.NODE_ENV === "development") {
console.warn("[Unleash Impression] Google Analytics not available");
}
}

View File

@ -30,15 +30,16 @@ const ImageSchema = new mongoose.Schema<IImage>({
required: true,
validate: {
validator: function(v: string) {
return /^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i.test(v);
// Поддержка изображений и видео форматов
return /^(image\/(jpeg|jpg|png|gif|webp|svg\+xml)|video\/(mp4|quicktime|webm))$/i.test(v);
},
message: 'Only image files are allowed'
message: 'Only image and video files are allowed (JPG, PNG, WebP, GIF, MP4, MOV, WebM)'
}
},
size: {
type: Number,
required: true,
max: 5 * 1024 * 1024 // 5MB максимум
max: 10 * 1024 * 1024 // 10MB максимум (увеличено для видео)
},
data: {
type: Buffer,