add ab test
This commit is contained in:
parent
2e1bca8466
commit
d47bedc427
@ -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": "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."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
"variants": []
|
||||
},
|
||||
{
|
||||
"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);
|
||||
}
|
||||
|
||||
// Проверяем 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
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 { 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>
|
||||
|
||||
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";
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": "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."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
"variants": []
|
||||
},
|
||||
{
|
||||
"id": "partner-gender",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user