add ab test
This commit is contained in:
parent
2e1bca8466
commit
d47bedc427
@ -4,6 +4,7 @@
|
|||||||
"title": "Soulmate V1",
|
"title": "Soulmate V1",
|
||||||
"description": "Soulmate",
|
"description": "Soulmate",
|
||||||
"firstScreenId": "onboarding",
|
"firstScreenId": "onboarding",
|
||||||
|
"googleAnalyticsId": "G-4N17LL3BB5",
|
||||||
"yandexMetrikaId": "104471567"
|
"yandexMetrikaId": "104471567"
|
||||||
},
|
},
|
||||||
"defaultTexts": {
|
"defaultTexts": {
|
||||||
@ -46,7 +47,62 @@
|
|||||||
"align": "center",
|
"align": "center",
|
||||||
"color": "default"
|
"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": {
|
"soulmatePortraitsDelivered": {
|
||||||
"image": "/soulmate-portrait-delivered-male.jpg",
|
"image": "/soulmate-portrait-delivered-male.jpg",
|
||||||
"text": {
|
"text": {
|
||||||
@ -123,40 +179,7 @@
|
|||||||
"showPrivacyTermsConsent": false
|
"showPrivacyTermsConsent": false
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"rules": [
|
"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"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"defaultNextScreenId": "partner-gender",
|
"defaultNextScreenId": "partner-gender",
|
||||||
"isEndScreen": false
|
"isEndScreen": false
|
||||||
},
|
},
|
||||||
@ -178,81 +201,7 @@
|
|||||||
],
|
],
|
||||||
"registrationFieldKey": "profile.gender"
|
"registrationFieldKey": "profile.gender"
|
||||||
},
|
},
|
||||||
"variants": [
|
"variants": []
|
||||||
{
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"screenId": "onboarding",
|
|
||||||
"operator": "includesAny",
|
|
||||||
"conditionType": "unleash",
|
|
||||||
"unleashFlag": "soulmate-onboarding-image",
|
|
||||||
"unleashVariants": [
|
|
||||||
"v2"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"overrides": {
|
|
||||||
"title": {
|
|
||||||
"text": "What’s 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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "partner-gender",
|
"id": "partner-gender",
|
||||||
|
|||||||
BIN
public/images/275472b0-30e0-47d7-a1ab-8090bc9fb236.mp4
Normal file
BIN
public/images/275472b0-30e0-47d7-a1ab-8090bc9fb236.mp4
Normal file
Binary file not shown.
BIN
public/images/90b8c77f-c0cd-475d-a4de-bcabb3708c59.png
Normal file
BIN
public/images/90b8c77f-c0cd-475d-a4de-bcabb3708c59.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
@ -145,6 +145,14 @@ async function downloadImagesFromDatabase(funnels) {
|
|||||||
imageUrls.add(screen.image.src);
|
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 в вариантах экрана
|
// Проверяем icon и image в вариантах экрана
|
||||||
if (screen.variants && Array.isArray(screen.variants)) {
|
if (screen.variants && Array.isArray(screen.variants)) {
|
||||||
for (const variant of screen.variants) {
|
for (const variant of screen.variants) {
|
||||||
@ -157,6 +165,13 @@ async function downloadImagesFromDatabase(funnels) {
|
|||||||
if (variant.overrides?.image?.src?.startsWith('/api/images/')) {
|
if (variant.overrides?.image?.src?.startsWith('/api/images/')) {
|
||||||
imageUrls.add(variant.overrides.image.src);
|
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}`);
|
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 в вариантах экрана
|
// Обновляем icon и image в вариантах экрана
|
||||||
if (screen.variants && Array.isArray(screen.variants)) {
|
if (screen.variants && Array.isArray(screen.variants)) {
|
||||||
for (const variant of screen.variants) {
|
for (const variant of screen.variants) {
|
||||||
@ -252,6 +281,19 @@ function updateImageUrlsInFunnels(funnels, imageMapping) {
|
|||||||
variant.overrides.image.src = newUrl;
|
variant.overrides.image.src = newUrl;
|
||||||
console.log(`🔗 Updated variant image URL: ${oldUrl} → ${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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,20 @@ import { Image } from '@/lib/models/Image';
|
|||||||
import { IS_FRONTEND_ONLY_BUILD } from '@/lib/runtime/buildVariant';
|
import { IS_FRONTEND_ONLY_BUILD } from '@/lib/runtime/buildVariant';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB (увеличено для видео)
|
||||||
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -34,14 +46,14 @@ export async function POST(request: NextRequest) {
|
|||||||
// Валидация файла
|
// Валидация файла
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'File too large. Maximum size is 5MB' },
|
{ error: 'File too large. Maximum size is 10MB' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
return NextResponse.json(
|
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 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
381
src/components/admin/builder/forms/MediaUpload.tsx
Normal file
381
src/components/admin/builder/forms/MediaUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
import { TextInput } from "@/components/ui/TextInput/TextInput";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Plus, Trash2 } from "lucide-react";
|
||||||
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
import type { BuilderScreen } from "@/lib/admin/builder/types";
|
||||||
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
|
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
|
||||||
@ -31,11 +31,13 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
|
|||||||
updates: Partial<NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>>
|
updates: Partial<NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>>
|
||||||
) => {
|
) => {
|
||||||
const base = screen.soulmatePortraitsDelivered ?? {};
|
const base = screen.soulmatePortraitsDelivered ?? {};
|
||||||
|
const nextDelivered = { ...base, ...updates };
|
||||||
|
|
||||||
|
// Важно для вариантов: сохраняем undefined значения!
|
||||||
|
// Система вариантов использует undefined для отслеживания удаленных полей
|
||||||
|
|
||||||
onUpdate({
|
onUpdate({
|
||||||
soulmatePortraitsDelivered: {
|
soulmatePortraitsDelivered: nextDelivered as NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>,
|
||||||
...base,
|
|
||||||
...updates,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -101,13 +103,17 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
|
|||||||
<h4 className="text-sm font-medium text-slate-700 mb-3">Блок доставленных портретов</h4>
|
<h4 className="text-sm font-medium text-slate-700 mb-3">Блок доставленных портретов</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение</span>
|
<span className="text-xs font-medium text-muted-foreground mb-2 block">Основное медиа (изображение/видео/GIF)</span>
|
||||||
<ImageUpload
|
<MediaUpload
|
||||||
currentValue={screen.soulmatePortraitsDelivered?.image}
|
currentMediaUrl={screen.soulmatePortraitsDelivered?.mediaUrl || screen.soulmatePortraitsDelivered?.image}
|
||||||
onImageSelect={(url) => updateDelivered({ image: url })}
|
currentMediaType={screen.soulmatePortraitsDelivered?.mediaType}
|
||||||
onImageRemove={() => updateDelivered({ image: undefined })}
|
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}
|
funnelId={screen.id}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Поддержка: изображения (JPG, PNG, WebP), видео (MP4, MOV), GIF с автовоспроизведением без звука
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Текст под изображением"
|
label="Текст под изображением"
|
||||||
@ -138,11 +144,12 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение</span>
|
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение (только фото, не видео)</span>
|
||||||
<ImageUpload
|
<MediaUpload
|
||||||
currentValue={avatar.src}
|
currentMediaUrl={avatar.src}
|
||||||
onImageSelect={(url) => updateAvatar(index, { src: url })}
|
currentMediaType="image"
|
||||||
onImageRemove={() => updateAvatar(index, { src: "" })}
|
onMediaSelect={(url) => updateAvatar(index, { src: url })}
|
||||||
|
onMediaRemove={() => updateAvatar(index, { src: "" })}
|
||||||
funnelId={screen.id}
|
funnelId={screen.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
24
src/components/funnel/FlagVariantFetcher.tsx
Normal file
24
src/components/funnel/FlagVariantFetcher.tsx
Normal 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
|
||||||
|
}
|
||||||
@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, type ReactNode } from "react";
|
import { useState, useMemo, useCallback, type ReactNode } from "react";
|
||||||
import { useFlagsStatus, useVariant } from "@unleash/proxy-client-react";
|
import { useFlagsStatus } from "@unleash/proxy-client-react";
|
||||||
import { UnleashContextProvider } from "@/lib/funnel/unleash";
|
import { UnleashContextProvider } from "@/lib/funnel/unleash";
|
||||||
import { FunnelLoadingScreen } from "./FunnelLoadingScreen";
|
import { FunnelLoadingScreen } from "./FunnelLoadingScreen";
|
||||||
|
import { FlagVariantFetcher } from "./FlagVariantFetcher";
|
||||||
import type { NavigationConditionDefinition } from "@/lib/funnel/types";
|
import type { NavigationConditionDefinition } from "@/lib/funnel/types";
|
||||||
|
|
||||||
interface FunnelUnleashWrapperProps {
|
interface FunnelUnleashWrapperProps {
|
||||||
@ -66,15 +67,24 @@ export function FunnelUnleashWrapper({
|
|||||||
return Array.from(flags);
|
return Array.from(flags);
|
||||||
}, [funnel.screens]);
|
}, [funnel.screens]);
|
||||||
|
|
||||||
// Получаем варианты для всех флагов
|
// Состояние для хранения вариантов флагов
|
||||||
const flagVariants = allFlags.map((flag) => {
|
const [loadedVariants, setLoadedVariants] = useState<Record<string, string>>({});
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
||||||
const variant = useVariant(flag);
|
// Колбэк для получения варианта от FlagVariantFetcher компонента
|
||||||
return {
|
const handleVariantLoaded = useCallback((flag: string, variant: string | undefined) => {
|
||||||
flag,
|
if (variant && variant !== "disabled") {
|
||||||
variant: variant?.name,
|
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(() => {
|
const activeVariants = useMemo(() => {
|
||||||
@ -82,15 +92,12 @@ export function FunnelUnleashWrapper({
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants: Record<string, string> = {};
|
if (process.env.NODE_ENV === "development") {
|
||||||
flagVariants.forEach(({ flag, variant }) => {
|
console.log("[FunnelUnleashWrapper] Active variants:", loadedVariants);
|
||||||
if (variant && variant !== "disabled") {
|
}
|
||||||
variants[flag] = variant;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return variants;
|
return loadedVariants;
|
||||||
}, [flagsReady, flagVariants]);
|
}, [flagsReady, loadedVariants]);
|
||||||
|
|
||||||
// Показываем loader пока флаги загружаются
|
// Показываем loader пока флаги загружаются
|
||||||
// Это предотвращает flash of unstyled content
|
// Это предотвращает flash of unstyled content
|
||||||
@ -100,6 +107,14 @@ export function FunnelUnleashWrapper({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<UnleashContextProvider activeVariants={activeVariants}>
|
<UnleashContextProvider activeVariants={activeVariants}>
|
||||||
|
{/* Рендерим FlagVariantFetcher для каждого флага */}
|
||||||
|
{allFlags.map((flag) => (
|
||||||
|
<FlagVariantFetcher
|
||||||
|
key={flag}
|
||||||
|
flag={flag}
|
||||||
|
onVariantLoaded={handleVariantLoaded}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{children}
|
{children}
|
||||||
</UnleashContextProvider>
|
</UnleashContextProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -62,6 +62,8 @@ export function SoulmatePortraitTemplate({
|
|||||||
{screen.soulmatePortraitsDelivered && (
|
{screen.soulmatePortraitsDelivered && (
|
||||||
<SoulmatePortraitsDelivered
|
<SoulmatePortraitsDelivered
|
||||||
image={screen.soulmatePortraitsDelivered.image}
|
image={screen.soulmatePortraitsDelivered.image}
|
||||||
|
mediaType={screen.soulmatePortraitsDelivered.mediaType}
|
||||||
|
mediaUrl={screen.soulmatePortraitsDelivered.mediaUrl}
|
||||||
textProps={
|
textProps={
|
||||||
screen.soulmatePortraitsDelivered.text
|
screen.soulmatePortraitsDelivered.text
|
||||||
? buildTypographyProps(screen.soulmatePortraitsDelivered.text, {
|
? buildTypographyProps(screen.soulmatePortraitsDelivered.text, {
|
||||||
|
|||||||
@ -4,35 +4,62 @@ import Typography, {
|
|||||||
TypographyProps,
|
TypographyProps,
|
||||||
} from "@/components/ui/Typography/Typography";
|
} from "@/components/ui/Typography/Typography";
|
||||||
|
|
||||||
|
type MediaType = "image" | "video" | "gif";
|
||||||
|
|
||||||
interface SoulmatePortraitsDeliveredProps extends React.ComponentProps<"div"> {
|
interface SoulmatePortraitsDeliveredProps extends React.ComponentProps<"div"> {
|
||||||
image?: string;
|
image?: string; // Legacy: используется как background если нет mediaUrl
|
||||||
|
mediaType?: MediaType;
|
||||||
|
mediaUrl?: string; // Приоритет над image
|
||||||
textProps?: TypographyProps<"p">;
|
textProps?: TypographyProps<"p">;
|
||||||
avatarsProps?: React.ComponentProps<typeof Avatars>;
|
avatarsProps?: React.ComponentProps<typeof Avatars>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SoulmatePortraitsDelivered({
|
export default function SoulmatePortraitsDelivered({
|
||||||
image,
|
image,
|
||||||
|
mediaType = "image",
|
||||||
|
mediaUrl,
|
||||||
avatarsProps,
|
avatarsProps,
|
||||||
textProps,
|
textProps,
|
||||||
...props
|
...props
|
||||||
}: SoulmatePortraitsDeliveredProps) {
|
}: SoulmatePortraitsDeliveredProps) {
|
||||||
|
// Определяем финальный URL медиа (приоритет у mediaUrl)
|
||||||
|
const finalMediaUrl = mediaUrl || image;
|
||||||
|
const finalMediaType = mediaUrl ? mediaType : "image";
|
||||||
|
const shouldRenderVideo = finalMediaType === "video" || finalMediaType === "gif";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${image})`,
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-end",
|
"relative flex flex-col items-center justify-end",
|
||||||
"w-full max-w-[336px] h-[220px]",
|
"w-full max-w-[336px] h-[220px]",
|
||||||
"p-5",
|
"p-5",
|
||||||
"rounded-3xl",
|
"rounded-3xl overflow-hidden",
|
||||||
"shadow-soulmate-portrait",
|
"shadow-soulmate-portrait",
|
||||||
"bg-cover bg-top bg-no-repeat",
|
|
||||||
props.className
|
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} />}
|
{avatarsProps && <Avatars {...avatarsProps} />}
|
||||||
{textProps && (
|
{textProps && (
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
|||||||
"title": "Soulmate V1",
|
"title": "Soulmate V1",
|
||||||
"description": "Soulmate",
|
"description": "Soulmate",
|
||||||
"firstScreenId": "onboarding",
|
"firstScreenId": "onboarding",
|
||||||
|
"googleAnalyticsId": "G-4N17LL3BB5",
|
||||||
"yandexMetrikaId": "104471567"
|
"yandexMetrikaId": "104471567"
|
||||||
},
|
},
|
||||||
"defaultTexts": {
|
"defaultTexts": {
|
||||||
@ -54,7 +55,62 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
|||||||
"align": "center",
|
"align": "center",
|
||||||
"color": "default"
|
"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": {
|
"soulmatePortraitsDelivered": {
|
||||||
"image": "/soulmate-portrait-delivered-male.jpg",
|
"image": "/soulmate-portrait-delivered-male.jpg",
|
||||||
"text": {
|
"text": {
|
||||||
@ -131,40 +187,7 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
|||||||
"showPrivacyTermsConsent": false
|
"showPrivacyTermsConsent": false
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"rules": [
|
"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"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"defaultNextScreenId": "partner-gender",
|
"defaultNextScreenId": "partner-gender",
|
||||||
"isEndScreen": false
|
"isEndScreen": false
|
||||||
},
|
},
|
||||||
@ -186,81 +209,7 @@ export const BAKED_FUNNELS: Record<string, FunnelDefinition> = {
|
|||||||
],
|
],
|
||||||
"registrationFieldKey": "profile.gender"
|
"registrationFieldKey": "profile.gender"
|
||||||
},
|
},
|
||||||
"variants": [
|
"variants": []
|
||||||
{
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"screenId": "onboarding",
|
|
||||||
"operator": "includesAny",
|
|
||||||
"conditionType": "unleash",
|
|
||||||
"unleashFlag": "soulmate-onboarding-image",
|
|
||||||
"unleashVariants": [
|
|
||||||
"v2"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"overrides": {
|
|
||||||
"title": {
|
|
||||||
"text": "What’s 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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "partner-gender",
|
"id": "partner-gender",
|
||||||
|
|||||||
@ -125,11 +125,12 @@ export interface NavigationDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Рекурсивный Partial для глубоких вложенных объектов
|
// Рекурсивный Partial для глубоких вложенных объектов
|
||||||
|
// Поддерживает null для явного удаления полей в вариантах экранов
|
||||||
type DeepPartial<T> = T extends object
|
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<
|
type ScreenVariantOverrides<T> = DeepPartial<
|
||||||
@ -359,7 +360,9 @@ export interface SoulmatePortraitScreenDefinition {
|
|||||||
subtitle?: SubtitleDefinition;
|
subtitle?: SubtitleDefinition;
|
||||||
description?: TypographyVariant; // Настраиваемый текст описания
|
description?: TypographyVariant; // Настраиваемый текст описания
|
||||||
soulmatePortraitsDelivered?: {
|
soulmatePortraitsDelivered?: {
|
||||||
image?: string;
|
image?: string; // Legacy support - используется как background если нет mediaUrl
|
||||||
|
mediaType?: "image" | "video" | "gif"; // Тип медиа контента
|
||||||
|
mediaUrl?: string; // URL видео/gif файла (приоритет над image)
|
||||||
text?: TypographyVariant;
|
text?: TypographyVariant;
|
||||||
avatars?: Array<{
|
avatars?: Array<{
|
||||||
src: string;
|
src: string;
|
||||||
|
|||||||
@ -10,13 +10,23 @@
|
|||||||
* @param variant - вариант который получил пользователь
|
* @param variant - вариант который получил пользователь
|
||||||
*/
|
*/
|
||||||
export function sendUnleashImpression(flag: string, variant: string | undefined) {
|
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 (!variant || variant === "disabled") {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log("[Unleash Impression] Skipped: invalid variant");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем что браузерное окружение
|
// Проверяем что браузерное окружение
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log("[Unleash Impression] Skipped: not browser environment");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,26 +45,29 @@ export function sendUnleashImpression(flag: string, variant: string | undefined)
|
|||||||
return;
|
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
|
// Отправляем событие в Google Analytics
|
||||||
if (window.gtag) {
|
window.gtag("event", "experiment_impression", {
|
||||||
window.gtag("event", "experiment_impression", {
|
app_name: "witlab-funnel",
|
||||||
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,
|
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,15 +30,16 @@ const ImageSchema = new mongoose.Schema<IImage>({
|
|||||||
required: true,
|
required: true,
|
||||||
validate: {
|
validate: {
|
||||||
validator: function(v: string) {
|
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: {
|
size: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
max: 5 * 1024 * 1024 // 5MB максимум
|
max: 10 * 1024 * 1024 // 10MB максимум (увеличено для видео)
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
type: Buffer,
|
type: Buffer,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user