Skip to content

Commit c210c67

Browse files
committed
feat: experiments v1
1 parent 25c7761 commit c210c67

File tree

10 files changed

+2671
-5
lines changed

10 files changed

+2671
-5
lines changed
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
'use client';
2+
3+
import { FlaskIcon, TrashIcon, WarningIcon } from '@phosphor-icons/react';
4+
import {
5+
AlertDialog,
6+
AlertDialogAction,
7+
AlertDialogCancel,
8+
AlertDialogContent,
9+
AlertDialogDescription,
10+
AlertDialogFooter,
11+
AlertDialogHeader,
12+
AlertDialogTitle,
13+
} from '@/components/ui/alert-dialog';
14+
15+
interface DeleteExperimentDialogProps {
16+
isOpen: boolean;
17+
onClose: () => void;
18+
onConfirm: () => void;
19+
experimentName?: string;
20+
isDeleting?: boolean;
21+
}
22+
23+
export function DeleteExperimentDialog({
24+
isOpen,
25+
onClose,
26+
onConfirm,
27+
experimentName,
28+
isDeleting = false,
29+
}: DeleteExperimentDialogProps) {
30+
return (
31+
<AlertDialog onOpenChange={onClose} open={isOpen}>
32+
<AlertDialogContent className="rounded-xl border-border/50">
33+
<AlertDialogHeader className="space-y-4">
34+
<div className="flex items-center gap-3">
35+
<div className="rounded-xl border border-destructive/20 bg-destructive/10 p-3">
36+
<TrashIcon
37+
className="h-6 w-6 text-destructive"
38+
size={16}
39+
weight="duotone"
40+
/>
41+
</div>
42+
<div>
43+
<AlertDialogTitle className="font-semibold text-foreground text-xl">
44+
Delete Experiment
45+
</AlertDialogTitle>
46+
<AlertDialogDescription className="mt-1 text-muted-foreground">
47+
This action cannot be undone
48+
</AlertDialogDescription>
49+
</div>
50+
</div>
51+
52+
<div className="rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950">
53+
<div className="flex items-start gap-3">
54+
<WarningIcon
55+
className="mt-0.5 h-5 w-5 flex-shrink-0 text-orange-600"
56+
size={16}
57+
weight="duotone"
58+
/>
59+
<div className="space-y-2">
60+
<p className="font-medium text-orange-600 text-sm">
61+
Warning: This will permanently delete the experiment
62+
</p>
63+
<div className="space-y-1 text-orange-600/80 text-xs">
64+
<p>• All experiment data and variants will be lost</p>
65+
<p>• Analytics and performance metrics will be removed</p>
66+
<p>• This action cannot be reversed</p>
67+
</div>
68+
</div>
69+
</div>
70+
</div>
71+
72+
{experimentName && (
73+
<div className="rounded-lg border bg-muted/30 p-4">
74+
<div className="flex items-center gap-3">
75+
<FlaskIcon
76+
className="h-5 w-5 text-muted-foreground"
77+
size={16}
78+
weight="duotone"
79+
/>
80+
<div>
81+
<p className="font-medium text-foreground text-sm">
82+
Experiment to delete:
83+
</p>
84+
<p className="mt-1 text-muted-foreground text-xs">
85+
{experimentName}
86+
</p>
87+
</div>
88+
</div>
89+
</div>
90+
)}
91+
</AlertDialogHeader>
92+
93+
<AlertDialogFooter className="gap-3">
94+
<AlertDialogCancel
95+
className="rounded-lg transition-all duration-200 hover:bg-muted"
96+
disabled={isDeleting}
97+
>
98+
Cancel
99+
</AlertDialogCancel>
100+
<AlertDialogAction
101+
className="relative rounded-lg bg-destructive transition-all duration-200 hover:bg-destructive/90"
102+
disabled={isDeleting}
103+
onClick={onConfirm}
104+
>
105+
{isDeleting && (
106+
<div className="absolute left-3">
107+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-destructive-foreground/30 border-t-destructive-foreground" />
108+
</div>
109+
)}
110+
<span className={isDeleting ? 'ml-6' : ''}>
111+
{isDeleting ? 'Deleting...' : 'Delete Experiment'}
112+
</span>
113+
</AlertDialogAction>
114+
</AlertDialogFooter>
115+
</AlertDialogContent>
116+
</AlertDialog>
117+
);
118+
}
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
'use client';
2+
3+
import {
4+
CalendarIcon,
5+
ChartLineIcon,
6+
CodeIcon,
7+
DotsThreeIcon,
8+
Eye,
9+
FlaskIcon,
10+
LinkIcon,
11+
PencilIcon,
12+
PlayIcon,
13+
StopIcon,
14+
TrashIcon,
15+
UsersIcon,
16+
} from '@phosphor-icons/react';
17+
import dayjs from 'dayjs';
18+
import { memo } from 'react';
19+
import { Badge } from '@/components/ui/badge';
20+
import { Button } from '@/components/ui/button';
21+
import { Card, CardContent, CardHeader } from '@/components/ui/card';
22+
import {
23+
DropdownMenu,
24+
DropdownMenuContent,
25+
DropdownMenuItem,
26+
DropdownMenuSeparator,
27+
DropdownMenuTrigger,
28+
} from '@/components/ui/dropdown-menu';
29+
import type { Experiment } from '@/hooks/use-experiments';
30+
31+
interface ExperimentCardProps {
32+
experiment: Experiment;
33+
onEdit: (experiment: Experiment) => void;
34+
onDelete: (experimentId: string) => void;
35+
onToggleStatus?: (
36+
experimentId: string,
37+
newStatus: 'running' | 'paused'
38+
) => void;
39+
}
40+
41+
const statusConfig = {
42+
draft: {
43+
label: 'Draft',
44+
variant: 'secondary' as const,
45+
color: 'text-muted-foreground',
46+
bgColor: 'bg-muted/50',
47+
},
48+
running: {
49+
label: 'Running',
50+
variant: 'default' as const,
51+
color: 'text-green-600',
52+
bgColor: 'bg-green-50 dark:bg-green-950',
53+
},
54+
paused: {
55+
label: 'Paused',
56+
variant: 'outline' as const,
57+
color: 'text-orange-600',
58+
bgColor: 'bg-orange-50 dark:bg-orange-950',
59+
},
60+
completed: {
61+
label: 'Completed',
62+
variant: 'secondary' as const,
63+
color: 'text-blue-600',
64+
bgColor: 'bg-blue-50 dark:bg-blue-950',
65+
},
66+
};
67+
68+
const getVariantIcon = (type: string, size = 14) => {
69+
switch (type) {
70+
case 'visual':
71+
return <Eye className="text-blue-600" size={size} weight="duotone" />;
72+
case 'redirect':
73+
return (
74+
<LinkIcon className="text-green-600" size={size} weight="duotone" />
75+
);
76+
case 'code':
77+
return (
78+
<CodeIcon className="text-purple-600" size={size} weight="duotone" />
79+
);
80+
default:
81+
return (
82+
<FlaskIcon
83+
className="text-muted-foreground"
84+
size={size}
85+
weight="duotone"
86+
/>
87+
);
88+
}
89+
};
90+
91+
export const ExperimentCard = memo(function ExperimentCardComponent({
92+
experiment,
93+
onEdit,
94+
onDelete,
95+
onToggleStatus,
96+
}: ExperimentCardProps) {
97+
const statusInfo = statusConfig[experiment.status];
98+
const hasVariants = Boolean(
99+
experiment.variants && experiment.variants.length > 0
100+
);
101+
const hasGoals = Boolean(experiment.goals && experiment.goals.length > 0);
102+
const variantTypes = experiment.variants
103+
? [...new Set(experiment.variants.map((v) => v.type))]
104+
: [];
105+
106+
const handleToggleStatus = () => {
107+
if (!onToggleStatus) {
108+
return;
109+
}
110+
const newStatus = experiment.status === 'running' ? 'paused' : 'running';
111+
onToggleStatus(experiment.id, newStatus);
112+
};
113+
114+
return (
115+
<Card className="group relative rounded border-border/50 transition-all duration-200 hover:border-primary/30 hover:shadow-lg">
116+
<CardHeader className="pb-4">
117+
<div className="flex items-start justify-between">
118+
<div className="flex-1 space-y-2">
119+
<div className="flex items-center gap-3">
120+
<div className="rounded border border-primary/20 bg-primary/10 p-2">
121+
<FlaskIcon
122+
className="h-4 w-4 text-primary"
123+
size={16}
124+
weight="duotone"
125+
/>
126+
</div>
127+
<div className="min-w-0 flex-1">
128+
<h3 className="truncate font-semibold text-foreground text-lg leading-tight">
129+
{experiment.name}
130+
</h3>
131+
{experiment.description && (
132+
<p className="line-clamp-2 text-muted-foreground text-sm leading-relaxed">
133+
{experiment.description}
134+
</p>
135+
)}
136+
</div>
137+
</div>
138+
139+
<div className="flex items-center gap-2">
140+
<Badge
141+
className={`font-medium ${statusInfo.bgColor} ${statusInfo.color}`}
142+
variant={statusInfo.variant}
143+
>
144+
{statusInfo.label}
145+
</Badge>
146+
<div className="flex items-center gap-1 text-muted-foreground text-xs">
147+
<UsersIcon size={12} weight="duotone" />
148+
<span>{experiment.trafficAllocation}% traffic</span>
149+
</div>
150+
</div>
151+
</div>
152+
153+
<DropdownMenu>
154+
<DropdownMenuTrigger asChild>
155+
<Button
156+
className="h-8 w-8 rounded opacity-0 transition-all duration-200 focus:opacity-100 group-hover:opacity-100"
157+
size="sm"
158+
variant="ghost"
159+
>
160+
<DotsThreeIcon className="h-4 w-4" size={16} weight="bold" />
161+
</Button>
162+
</DropdownMenuTrigger>
163+
<DropdownMenuContent align="end" className="w-48">
164+
<DropdownMenuItem onClick={() => onEdit(experiment)}>
165+
<PencilIcon className="mr-2 h-4 w-4" size={16} />
166+
Edit Experiment
167+
</DropdownMenuItem>
168+
{onToggleStatus &&
169+
(experiment.status === 'running' ||
170+
experiment.status === 'paused') && (
171+
<DropdownMenuItem onClick={handleToggleStatus}>
172+
{experiment.status === 'running' ? (
173+
<>
174+
<StopIcon className="mr-2 h-4 w-4" size={16} />
175+
Pause
176+
</>
177+
) : (
178+
<>
179+
<PlayIcon className="mr-2 h-4 w-4" size={16} />
180+
Resume
181+
</>
182+
)}
183+
</DropdownMenuItem>
184+
)}
185+
<DropdownMenuSeparator />
186+
<DropdownMenuItem
187+
className="text-destructive focus:text-destructive"
188+
onClick={() => onDelete(experiment.id)}
189+
>
190+
<TrashIcon className="mr-2 h-4 w-4" size={16} />
191+
Delete
192+
</DropdownMenuItem>
193+
</DropdownMenuContent>
194+
</DropdownMenu>
195+
</div>
196+
</CardHeader>
197+
198+
<CardContent className="space-y-4">
199+
{/* Experiment Details */}
200+
<div className="grid grid-cols-2 gap-4 text-sm">
201+
<div className="space-y-1">
202+
<div className="flex items-center gap-1 text-muted-foreground">
203+
<FlaskIcon size={12} weight="duotone" />
204+
<span>Variants</span>
205+
</div>
206+
<div className="font-medium text-foreground">
207+
{hasVariants ? experiment.variants?.length || 0 : 0}
208+
</div>
209+
</div>
210+
<div className="space-y-1">
211+
<div className="flex items-center gap-1 text-muted-foreground">
212+
<ChartLineIcon size={12} weight="duotone" />
213+
<span>Goals</span>
214+
</div>
215+
<div className="font-medium text-foreground">
216+
{hasGoals ? experiment.goals?.length || 0 : 0}
217+
</div>
218+
</div>
219+
</div>
220+
221+
{/* Variant Types */}
222+
{variantTypes.length > 0 && (
223+
<div className="space-y-2">
224+
<div className="text-muted-foreground text-xs">Variant Types:</div>
225+
<div className="flex gap-2">
226+
{variantTypes.map((type) => (
227+
<div
228+
className="flex items-center gap-1 rounded-md bg-muted/50 px-2 py-1 text-xs"
229+
key={type}
230+
>
231+
{getVariantIcon(type, 12)}
232+
<span className="capitalize">{type}</span>
233+
</div>
234+
))}
235+
</div>
236+
</div>
237+
)}
238+
239+
{/* Timestamps */}
240+
<div className="space-y-2 border-border/50 border-t pt-3">
241+
<div className="flex items-center justify-between text-xs">
242+
<div className="flex items-center gap-1 text-muted-foreground">
243+
<CalendarIcon size={12} weight="duotone" />
244+
<span>Created</span>
245+
</div>
246+
<span className="font-medium text-foreground">
247+
{dayjs(experiment.createdAt).format('MMM D, YYYY')}
248+
</span>
249+
</div>
250+
{experiment.startDate && (
251+
<div className="flex items-center justify-between text-xs">
252+
<div className="flex items-center gap-1 text-muted-foreground">
253+
<PlayIcon size={12} weight="duotone" />
254+
<span>Started</span>
255+
</div>
256+
<span className="font-medium text-foreground">
257+
{dayjs(experiment.startDate).format('MMM D, YYYY')}
258+
</span>
259+
</div>
260+
)}
261+
{experiment.endDate && (
262+
<div className="flex items-center justify-between text-xs">
263+
<div className="flex items-center gap-1 text-muted-foreground">
264+
<StopIcon size={12} weight="duotone" />
265+
<span>Ended</span>
266+
</div>
267+
<span className="font-medium text-foreground">
268+
{dayjs(experiment.endDate).format('MMM D, YYYY')}
269+
</span>
270+
</div>
271+
)}
272+
</div>
273+
</CardContent>
274+
</Card>
275+
);
276+
});

0 commit comments

Comments
 (0)