pxengine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -0
- package/config/tailwind-preset.js +106 -0
- package/dist/index.d.mts +1259 -0
- package/dist/index.d.ts +1259 -0
- package/dist/index.js +5175 -0
- package/dist/index.mjs +4929 -0
- package/package.json +94 -0
- package/src/atoms/AccordionAtom.tsx +44 -0
- package/src/atoms/AlertAtom.tsx +46 -0
- package/src/atoms/AlertDialogAtom.tsx +66 -0
- package/src/atoms/AspectRatioAtom.tsx +27 -0
- package/src/atoms/AvatarAtom.tsx +20 -0
- package/src/atoms/BadgeAtom.tsx +25 -0
- package/src/atoms/BreadcrumbAtom.tsx +36 -0
- package/src/atoms/ButtonAtom.tsx +63 -0
- package/src/atoms/CalendarAtom.tsx +24 -0
- package/src/atoms/CardAtom.tsx +64 -0
- package/src/atoms/CarouselAtom.tsx +40 -0
- package/src/atoms/CollapsibleAtom.tsx +44 -0
- package/src/atoms/CommandAtom.tsx +46 -0
- package/src/atoms/DialogAtom.tsx +68 -0
- package/src/atoms/InputAtom.tsx +162 -0
- package/src/atoms/LayoutAtom.tsx +43 -0
- package/src/atoms/PaginationAtom.tsx +49 -0
- package/src/atoms/PopoverAtom.tsx +40 -0
- package/src/atoms/ProgressAtom.tsx +15 -0
- package/src/atoms/ScrollAreaAtom.tsx +31 -0
- package/src/atoms/SeparatorAtom.tsx +16 -0
- package/src/atoms/SheetAtom.tsx +72 -0
- package/src/atoms/SkeletonAtom.tsx +22 -0
- package/src/atoms/SpinnerAtom.tsx +26 -0
- package/src/atoms/TableAtom.tsx +58 -0
- package/src/atoms/TabsAtom.tsx +40 -0
- package/src/atoms/TextAtom.tsx +35 -0
- package/src/atoms/TooltipAtom.tsx +39 -0
- package/src/atoms/index.ts +28 -0
- package/src/components/index.ts +178 -0
- package/src/components/ui/accordion.tsx +56 -0
- package/src/components/ui/alert-dialog.tsx +139 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/aspect-ratio.tsx +5 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/breadcrumb.tsx +115 -0
- package/src/components/ui/button-group.tsx +83 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/calendar.tsx +213 -0
- package/src/components/ui/card.tsx +79 -0
- package/src/components/ui/carousel.tsx +260 -0
- package/src/components/ui/chart.tsx +367 -0
- package/src/components/ui/checkbox.tsx +28 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/command.tsx +153 -0
- package/src/components/ui/context-menu.tsx +198 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/drawer.tsx +116 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/empty.tsx +104 -0
- package/src/components/ui/field.tsx +244 -0
- package/src/components/ui/form.tsx +176 -0
- package/src/components/ui/hover-card.tsx +27 -0
- package/src/components/ui/input-group.tsx +168 -0
- package/src/components/ui/input-otp.tsx +69 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/item.tsx +193 -0
- package/src/components/ui/kbd.tsx +28 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/menubar.tsx +254 -0
- package/src/components/ui/navigation-menu.tsx +128 -0
- package/src/components/ui/pagination.tsx +117 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/radio-group.tsx +42 -0
- package/src/components/ui/resizable.tsx +45 -0
- package/src/components/ui/scroll-area.tsx +46 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +29 -0
- package/src/components/ui/sheet.tsx +140 -0
- package/src/components/ui/sidebar.tsx +771 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/slider.tsx +26 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/toggle-group.tsx +61 -0
- package/src/components/ui/toggle.tsx +43 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/index.ts +24 -0
- package/src/lib/countries.ts +203 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/utils.ts +15 -0
- package/src/lib/validators/index.ts +1 -0
- package/src/lib/validators/theme.ts +148 -0
- package/src/molecules/creator-discovery/CampaignSeedCard/CampaignSeedCard.tsx +123 -0
- package/src/molecules/creator-discovery/CampaignSeedCard/CampaignSeedCard.types.ts +13 -0
- package/src/molecules/creator-discovery/CampaignSeedCard/index.ts +2 -0
- package/src/molecules/creator-discovery/MCQCard/MCQCard.tsx +165 -0
- package/src/molecules/creator-discovery/MCQCard/MCQCard.types.ts +71 -0
- package/src/molecules/creator-discovery/MCQCard/index.ts +2 -0
- package/src/molecules/creator-discovery/SearchSpecCard/CustomFieldRenderers.tsx +334 -0
- package/src/molecules/creator-discovery/SearchSpecCard/SearchSpecCard.tsx +111 -0
- package/src/molecules/creator-discovery/SearchSpecCard/SearchSpecCard.types.ts +18 -0
- package/src/molecules/creator-discovery/SearchSpecCard/index.ts +3 -0
- package/src/molecules/creator-discovery/index.ts +3 -0
- package/src/molecules/generic/ActionButton/ActionButton.tsx +137 -0
- package/src/molecules/generic/ActionButton/ActionButton.types.ts +68 -0
- package/src/molecules/generic/ActionButton/index.ts +2 -0
- package/src/molecules/generic/EditableField/EditableField.tsx +229 -0
- package/src/molecules/generic/EditableField/EditableField.types.ts +73 -0
- package/src/molecules/generic/EditableField/index.ts +2 -0
- package/src/molecules/generic/FormCard/FormCard.tsx +136 -0
- package/src/molecules/generic/FormCard/FormCard.types.ts +93 -0
- package/src/molecules/generic/FormCard/index.ts +2 -0
- package/src/molecules/generic/index.ts +3 -0
- package/src/molecules/index.ts +2 -0
- package/src/render/PXEngineRenderer.tsx +272 -0
- package/src/render/index.ts +1 -0
- package/src/styles/globals.css +146 -0
- package/src/types/atoms.ts +294 -0
- package/src/types/common.ts +116 -0
- package/src/types/index.ts +3 -0
- package/src/types/molecules.ts +54 -0
- package/src/types/schema.ts +12 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export interface MCQOption {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface MCQCardProps {
|
|
7
|
+
/**
|
|
8
|
+
* The question being asked
|
|
9
|
+
*/
|
|
10
|
+
question: string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The options to choose from
|
|
14
|
+
*/
|
|
15
|
+
options: Record<string, string>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The key of the recommended option
|
|
19
|
+
*/
|
|
20
|
+
recommended?: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The currently selected option key
|
|
24
|
+
*/
|
|
25
|
+
selectedOption?: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Triggered when an option is selected
|
|
29
|
+
*/
|
|
30
|
+
onSelect?: (key: string) => void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Triggered when the user clicks continue
|
|
34
|
+
*/
|
|
35
|
+
onProceed?: (key: string) => void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the message is the latest
|
|
39
|
+
*/
|
|
40
|
+
isLatestMessage?: boolean;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Countdown in seconds
|
|
44
|
+
*/
|
|
45
|
+
countdown?: number;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the countdown is paused
|
|
49
|
+
*/
|
|
50
|
+
isPaused?: boolean;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pause/Resume handler
|
|
54
|
+
*/
|
|
55
|
+
onPause?: () => void;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Loading state during submission
|
|
59
|
+
*/
|
|
60
|
+
isLoading?: boolean;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Custom className
|
|
64
|
+
*/
|
|
65
|
+
className?: string;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Who made the final selection (for historical view)
|
|
69
|
+
*/
|
|
70
|
+
selectionStatus?: "user" | "agent";
|
|
71
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { ChevronDown, Check } from "lucide-react";
|
|
3
|
+
import { countries } from "@/lib/countries";
|
|
4
|
+
import { Badge } from "@/components";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// COUNTRY SELECT FIELD
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export const CountrySelectEdit = ({
|
|
12
|
+
value,
|
|
13
|
+
onChange,
|
|
14
|
+
}: {
|
|
15
|
+
value: any;
|
|
16
|
+
onChange: (value: any) => void;
|
|
17
|
+
}) => {
|
|
18
|
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
19
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
20
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
24
|
+
if (
|
|
25
|
+
dropdownRef.current &&
|
|
26
|
+
!dropdownRef.current.contains(event.target as Node)
|
|
27
|
+
) {
|
|
28
|
+
setIsDropdownOpen(false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
32
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
const inputValue = Array.isArray(value) ? value : [];
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-3">
|
|
39
|
+
<div className="relative" ref={dropdownRef}>
|
|
40
|
+
<div
|
|
41
|
+
className="flex-1 bg-white border border-gray200 rounded-md px-3 py-2 text-sm cursor-pointer flex items-center justify-between font-medium hover:border-purple500 transition-colors"
|
|
42
|
+
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
43
|
+
>
|
|
44
|
+
<span className="text-gray-700">
|
|
45
|
+
{inputValue.length > 0
|
|
46
|
+
? `${inputValue.length} ${inputValue.length === 1 ? "country" : "countries"} selected`
|
|
47
|
+
: "Select countries..."}
|
|
48
|
+
</span>
|
|
49
|
+
<ChevronDown
|
|
50
|
+
className={cn(
|
|
51
|
+
"h-4 w-4 text-gray-400 transition-transform",
|
|
52
|
+
isDropdownOpen && "rotate-180",
|
|
53
|
+
)}
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{isDropdownOpen && (
|
|
58
|
+
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray200 rounded-lg shadow-xl z-50 max-h-60 overflow-hidden animate-in fade-in slide-in-from-top-1">
|
|
59
|
+
<div className="p-2 border-b border-gray-100">
|
|
60
|
+
<input
|
|
61
|
+
type="text"
|
|
62
|
+
placeholder="Search countries..."
|
|
63
|
+
value={searchTerm}
|
|
64
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
65
|
+
className="w-full px-3 py-1.5 text-sm border border-gray-100 rounded-md bg-gray-50 focus:outline-none focus:ring-2 focus:ring-purple500/20 focus:border-purple500"
|
|
66
|
+
onClick={(e) => e.stopPropagation()}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="max-h-40 overflow-y-auto p-1">
|
|
71
|
+
{countries
|
|
72
|
+
.filter(
|
|
73
|
+
(country) =>
|
|
74
|
+
country.name
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.includes(searchTerm.toLowerCase()) ||
|
|
77
|
+
country.code
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.includes(searchTerm.toLowerCase()),
|
|
80
|
+
)
|
|
81
|
+
.map((country) => (
|
|
82
|
+
<div
|
|
83
|
+
key={country.code}
|
|
84
|
+
className={cn(
|
|
85
|
+
"flex items-center justify-between px-3 py-2 rounded-md hover:bg-purple50 cursor-pointer transition-colors",
|
|
86
|
+
inputValue.includes(country.code) && "bg-purple50/50",
|
|
87
|
+
)}
|
|
88
|
+
onClick={(e) => {
|
|
89
|
+
e.stopPropagation();
|
|
90
|
+
const currentGeography = inputValue;
|
|
91
|
+
if (currentGeography.includes(country.code)) {
|
|
92
|
+
onChange(
|
|
93
|
+
currentGeography.filter(
|
|
94
|
+
(c: string) => c !== country.code,
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
} else {
|
|
98
|
+
onChange([...currentGeography, country.code]);
|
|
99
|
+
}
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<div className="flex items-center gap-2">
|
|
103
|
+
<span className="text-sm text-gray-700">
|
|
104
|
+
{country.name}
|
|
105
|
+
</span>
|
|
106
|
+
<span className="text-xs text-gray-400 font-mono">
|
|
107
|
+
{country.code}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
{inputValue.includes(country.code) && (
|
|
111
|
+
<Check className="h-4 w-4 text-purple500" />
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{inputValue.length > 0 && (
|
|
121
|
+
<div className="flex flex-wrap gap-1.5">
|
|
122
|
+
{inputValue.map((countryCode: string) => (
|
|
123
|
+
<Badge
|
|
124
|
+
key={countryCode}
|
|
125
|
+
variant="secondary"
|
|
126
|
+
className="bg-purple50 text-purple700 border-purple100 hover:bg-purple100 cursor-default flex items-center gap-1 pr-1"
|
|
127
|
+
>
|
|
128
|
+
{countryCode}
|
|
129
|
+
<button
|
|
130
|
+
onClick={() =>
|
|
131
|
+
onChange(inputValue.filter((c: string) => c !== countryCode))
|
|
132
|
+
}
|
|
133
|
+
className="hover:bg-purple200 rounded-full p-0.5 transition-colors"
|
|
134
|
+
>
|
|
135
|
+
<ChevronDown className="h-3 w-3 rotate-45" />{" "}
|
|
136
|
+
{/* Close icon substitute */}
|
|
137
|
+
</button>
|
|
138
|
+
</Badge>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const CountrySelectDisplay = ({ value }: { value: any }) => {
|
|
147
|
+
if (!value || !Array.isArray(value) || value.length === 0) {
|
|
148
|
+
return <span className="text-muted-foreground italic">Not specified</span>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="flex flex-wrap gap-1.5">
|
|
153
|
+
{value.map((countryCode: string) => (
|
|
154
|
+
<Badge
|
|
155
|
+
key={countryCode}
|
|
156
|
+
variant="outline"
|
|
157
|
+
className="bg-gray-50 text-gray-700 border-gray-200"
|
|
158
|
+
>
|
|
159
|
+
{countryCode}
|
|
160
|
+
</Badge>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// KEYWORD BUNDLES FIELD
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
export const KeywordBundlesEdit = ({
|
|
171
|
+
value,
|
|
172
|
+
onChange,
|
|
173
|
+
}: {
|
|
174
|
+
value: any;
|
|
175
|
+
onChange: (value: any) => void;
|
|
176
|
+
}) => {
|
|
177
|
+
const bundles = Array.isArray(value) ? value : [];
|
|
178
|
+
|
|
179
|
+
const groups: { [priority: number]: { bundle: any; index: number }[] } = {};
|
|
180
|
+
bundles.forEach((b: any, idx: number) => {
|
|
181
|
+
const p = Number(b?.priority) || 1;
|
|
182
|
+
if (!groups[p]) groups[p] = [];
|
|
183
|
+
groups[p].push({ bundle: b, index: idx });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const sortedPriorities = Object.keys(groups)
|
|
187
|
+
.map((n) => parseInt(n))
|
|
188
|
+
.sort((a, b) => a - b);
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="space-y-6 pt-2">
|
|
192
|
+
{sortedPriorities.map((priority) => (
|
|
193
|
+
<div key={priority} className="space-y-3">
|
|
194
|
+
<div className="flex items-center gap-2">
|
|
195
|
+
<Badge className="bg-purple500 hover:bg-purple500">
|
|
196
|
+
Priority {priority}
|
|
197
|
+
</Badge>
|
|
198
|
+
<div className="h-px flex-1 bg-gray-100" />
|
|
199
|
+
</div>
|
|
200
|
+
{groups[priority].map(({ bundle, index: bundleIndex }) => (
|
|
201
|
+
<div
|
|
202
|
+
key={bundleIndex}
|
|
203
|
+
className="bg-gray-50/50 border border-gray-100 rounded-xl p-4 transition-all hover:bg-gray-50 hover:border-gray-200 shadow-sm"
|
|
204
|
+
>
|
|
205
|
+
<div className="flex flex-col gap-3">
|
|
206
|
+
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest pl-1">
|
|
207
|
+
Keywords
|
|
208
|
+
</label>
|
|
209
|
+
<div className="space-y-3">
|
|
210
|
+
<div className="flex flex-wrap gap-1.5">
|
|
211
|
+
{Array.isArray(bundle.keywords) &&
|
|
212
|
+
bundle.keywords.map((keyword: string, kIndex: number) => (
|
|
213
|
+
<Badge
|
|
214
|
+
key={kIndex}
|
|
215
|
+
className="bg-white border-gray-200 text-gray-700 hover:bg-red-50 hover:text-red-600 hover:border-red-100 transition-all cursor-pointer group"
|
|
216
|
+
onClick={() => {
|
|
217
|
+
const updatedBundles = [...bundles];
|
|
218
|
+
updatedBundles[bundleIndex] = {
|
|
219
|
+
...bundle,
|
|
220
|
+
keywords: bundle.keywords.filter(
|
|
221
|
+
(_: string, i: number) => i !== kIndex,
|
|
222
|
+
),
|
|
223
|
+
};
|
|
224
|
+
onChange(updatedBundles);
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{keyword}
|
|
228
|
+
<span className="ml-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
229
|
+
×
|
|
230
|
+
</span>
|
|
231
|
+
</Badge>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
<div className="flex gap-2">
|
|
235
|
+
<input
|
|
236
|
+
type="text"
|
|
237
|
+
placeholder="Add keyword..."
|
|
238
|
+
className="flex-1 px-3 py-1.5 text-sm border border-gray-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500"
|
|
239
|
+
onKeyPress={(e) => {
|
|
240
|
+
if (e.key === "Enter") {
|
|
241
|
+
const input = e.target as HTMLInputElement;
|
|
242
|
+
const val = input.value.trim();
|
|
243
|
+
if (val) {
|
|
244
|
+
const updatedBundles = [...bundles];
|
|
245
|
+
updatedBundles[bundleIndex] = {
|
|
246
|
+
...bundle,
|
|
247
|
+
keywords: [...(bundle.keywords || []), val],
|
|
248
|
+
};
|
|
249
|
+
onChange(updatedBundles);
|
|
250
|
+
input.value = "";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
<div className="flex flex-col gap-1.5 mt-1">
|
|
258
|
+
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-widest pl-1">
|
|
259
|
+
Priority Level (1-5)
|
|
260
|
+
</label>
|
|
261
|
+
<input
|
|
262
|
+
type="number"
|
|
263
|
+
min="1"
|
|
264
|
+
max="5"
|
|
265
|
+
value={bundle.priority || 1}
|
|
266
|
+
onChange={(e) => {
|
|
267
|
+
const updatedBundles = [...bundles];
|
|
268
|
+
updatedBundles[bundleIndex] = {
|
|
269
|
+
...bundle,
|
|
270
|
+
priority: parseInt(e.target.value) || 1,
|
|
271
|
+
};
|
|
272
|
+
onChange(updatedBundles);
|
|
273
|
+
}}
|
|
274
|
+
className="w-20 px-3 py-1.5 text-sm border border-gray-200 rounded-md bg-white focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 outline-none"
|
|
275
|
+
/>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
))}
|
|
280
|
+
</div>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const KeywordBundlesDisplay = ({ value }: { value: any }) => {
|
|
287
|
+
const bundles = Array.isArray(value) ? value : [];
|
|
288
|
+
if (bundles.length === 0)
|
|
289
|
+
return <span className="text-muted-foreground italic">Not specified</span>;
|
|
290
|
+
|
|
291
|
+
const groups: { [priority: number]: string[] } = {};
|
|
292
|
+
bundles.forEach((b: any) => {
|
|
293
|
+
const p = Number(b?.priority) || 1;
|
|
294
|
+
const keywords = Array.isArray(b?.keywords) ? b.keywords : [];
|
|
295
|
+
if (!groups[p]) groups[p] = [];
|
|
296
|
+
groups[p].push(...keywords);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const sortedPriorities = Object.keys(groups)
|
|
300
|
+
.map((n) => parseInt(n))
|
|
301
|
+
.sort((a, b) => a - b);
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<div className="space-y-4 pt-1">
|
|
305
|
+
{sortedPriorities.map((priority) => {
|
|
306
|
+
const deduped = Array.from(new Set(groups[priority]));
|
|
307
|
+
return (
|
|
308
|
+
<div key={priority} className="space-y-2">
|
|
309
|
+
<div className="flex items-center gap-2">
|
|
310
|
+
<Badge
|
|
311
|
+
variant="outline"
|
|
312
|
+
className="text-[10px] uppercase tracking-wider text-purple600 border-purple100 bg-purple50/30"
|
|
313
|
+
>
|
|
314
|
+
Priority {priority}
|
|
315
|
+
</Badge>
|
|
316
|
+
<div className="h-px flex-1 bg-gray-100/50" />
|
|
317
|
+
</div>
|
|
318
|
+
<div className="flex flex-wrap gap-1.5">
|
|
319
|
+
{deduped.map((keyword: string) => (
|
|
320
|
+
<Badge
|
|
321
|
+
key={keyword}
|
|
322
|
+
variant="secondary"
|
|
323
|
+
className="bg-white border-gray-200 text-gray-700 font-medium"
|
|
324
|
+
>
|
|
325
|
+
{keyword}
|
|
326
|
+
</Badge>
|
|
327
|
+
))}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { SearchSpecCardProps } from "./SearchSpecCard.types";
|
|
3
|
+
import { FormCard } from "../../generic/FormCard";
|
|
4
|
+
import { FieldConfig } from "@/types/common";
|
|
5
|
+
import {
|
|
6
|
+
CountrySelectEdit,
|
|
7
|
+
CountrySelectDisplay,
|
|
8
|
+
KeywordBundlesEdit,
|
|
9
|
+
KeywordBundlesDisplay,
|
|
10
|
+
} from "./CustomFieldRenderers";
|
|
11
|
+
import { CheckCircle2 } from "lucide-react";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default field configuration for Search Specification
|
|
15
|
+
*/
|
|
16
|
+
export const SEARCH_SPEC_FIELDS: FieldConfig[] = [
|
|
17
|
+
{
|
|
18
|
+
key: "platforms",
|
|
19
|
+
label: "Platforms",
|
|
20
|
+
type: "select", // Changed to select for simplicity in form, or custom if multi-select needed
|
|
21
|
+
placeholder: "Select platforms",
|
|
22
|
+
options: ["Instagram", "YouTube", "TikTok"],
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
key: "follower_range",
|
|
26
|
+
label: "Follower Range",
|
|
27
|
+
type: "slider",
|
|
28
|
+
placeholder: "Not specified",
|
|
29
|
+
sliderConfig: {
|
|
30
|
+
min: 0,
|
|
31
|
+
max: 5000000,
|
|
32
|
+
step: 10000,
|
|
33
|
+
formatValue: (value: any) => {
|
|
34
|
+
if (!value || typeof value !== "object") return "0 - 2.5M followers";
|
|
35
|
+
const formatNum = (n: number) => {
|
|
36
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
37
|
+
if (n >= 1000) return `${(n / 1000).toFixed(0)}K`;
|
|
38
|
+
return n.toLocaleString();
|
|
39
|
+
};
|
|
40
|
+
return `${formatNum(value.min || 0)} - ${formatNum(value.max || 2500000)} followers`;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: "geography",
|
|
46
|
+
label: "Geography",
|
|
47
|
+
type: "custom",
|
|
48
|
+
renderEdit: (value, onChange) => (
|
|
49
|
+
<CountrySelectEdit value={value} onChange={onChange} />
|
|
50
|
+
),
|
|
51
|
+
renderDisplay: (value) => <CountrySelectDisplay value={value} />,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
key: "keyword_bundles",
|
|
55
|
+
label: "Keyword Bundles",
|
|
56
|
+
type: "custom",
|
|
57
|
+
renderEdit: (value, onChange) => (
|
|
58
|
+
<KeywordBundlesEdit value={value} onChange={onChange} />
|
|
59
|
+
),
|
|
60
|
+
renderDisplay: (value) => <KeywordBundlesDisplay value={value} />,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: "desired_candidate_count",
|
|
64
|
+
label: "Initial Creators",
|
|
65
|
+
type: "number",
|
|
66
|
+
numberConfig: {
|
|
67
|
+
min: 1,
|
|
68
|
+
formatValue: (value: any) => `${value || 0} creators`,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* SearchSpecCard
|
|
75
|
+
*
|
|
76
|
+
* A domain-specific molecule for the Creator Discovery workflow.
|
|
77
|
+
* Encapsulates search settings like platforms, ranges, and custom keyword bundles.
|
|
78
|
+
*/
|
|
79
|
+
export const SearchSpecCard = React.memo<SearchSpecCardProps>(
|
|
80
|
+
({
|
|
81
|
+
selectionStatus,
|
|
82
|
+
isLatestMessage = true,
|
|
83
|
+
className,
|
|
84
|
+
...formCardProps
|
|
85
|
+
}) => {
|
|
86
|
+
return (
|
|
87
|
+
<FormCard
|
|
88
|
+
{...formCardProps}
|
|
89
|
+
title={formCardProps.title || "Creator Search Settings"}
|
|
90
|
+
fields={SEARCH_SPEC_FIELDS}
|
|
91
|
+
className={className}
|
|
92
|
+
footer={
|
|
93
|
+
!isLatestMessage && selectionStatus ? (
|
|
94
|
+
<div className="flex justify-end items-center gap-1.5 text-green-600 text-xs font-semibold py-1">
|
|
95
|
+
<CheckCircle2 className="h-4 w-4" />
|
|
96
|
+
<span>
|
|
97
|
+
{selectionStatus === "agent"
|
|
98
|
+
? "Selected by Agent"
|
|
99
|
+
: "Selected by User"}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
) : (
|
|
103
|
+
formCardProps.footer
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
SearchSpecCard.displayName = "SearchSpecCard";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { FormCardProps } from "../../generic/FormCard";
|
|
2
|
+
|
|
3
|
+
export interface SearchSpecCardProps extends Omit<FormCardProps, "fields"> {
|
|
4
|
+
/**
|
|
5
|
+
* Version of the search specification
|
|
6
|
+
*/
|
|
7
|
+
version?: number;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Status of the selection
|
|
11
|
+
*/
|
|
12
|
+
selectionStatus?: "user" | "agent";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the message is the latest
|
|
16
|
+
*/
|
|
17
|
+
isLatestMessage?: boolean;
|
|
18
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { ActionButtonProps } from "./ActionButton.types";
|
|
3
|
+
import { Button } from "@/components";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
import { Pause, Play, ArrowRight, Loader2 } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ActionButton
|
|
9
|
+
*
|
|
10
|
+
* A powerful button molecule that supports:
|
|
11
|
+
* - Auto-countdown for agent workflows
|
|
12
|
+
* - Pause/Resume toggle
|
|
13
|
+
* - Loading and Disabled states
|
|
14
|
+
* - Custom themes
|
|
15
|
+
*/
|
|
16
|
+
export const ActionButton = React.memo<ActionButtonProps>(
|
|
17
|
+
({
|
|
18
|
+
label,
|
|
19
|
+
secondaryLabel,
|
|
20
|
+
countdown: countdownProp,
|
|
21
|
+
isPaused = false,
|
|
22
|
+
onPause,
|
|
23
|
+
onProceed,
|
|
24
|
+
variant = "default",
|
|
25
|
+
size = "default",
|
|
26
|
+
disabled = false,
|
|
27
|
+
isLoading = false,
|
|
28
|
+
className,
|
|
29
|
+
showCountdown = true,
|
|
30
|
+
}) => {
|
|
31
|
+
const [timeLeft, setTimeLeft] = useState(countdownProp || 0);
|
|
32
|
+
|
|
33
|
+
// Sync with prop
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (countdownProp !== undefined) {
|
|
36
|
+
setTimeLeft(countdownProp);
|
|
37
|
+
}
|
|
38
|
+
}, [countdownProp]);
|
|
39
|
+
|
|
40
|
+
// Countdown logic
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (
|
|
43
|
+
countdownProp === undefined ||
|
|
44
|
+
countdownProp <= 0 ||
|
|
45
|
+
isPaused ||
|
|
46
|
+
isLoading ||
|
|
47
|
+
disabled
|
|
48
|
+
) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const timer = setInterval(() => {
|
|
53
|
+
setTimeLeft((prev) => {
|
|
54
|
+
if (prev <= 1) {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
onProceed();
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
return prev - 1;
|
|
60
|
+
});
|
|
61
|
+
}, 1000);
|
|
62
|
+
|
|
63
|
+
return () => clearInterval(timer);
|
|
64
|
+
}, [countdownProp, isPaused, isLoading, disabled, onProceed]);
|
|
65
|
+
|
|
66
|
+
const progress = countdownProp ? (timeLeft / countdownProp) * 100 : 0;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={cn("inline-flex items-center gap-2", className)}>
|
|
70
|
+
<Button
|
|
71
|
+
variant={variant}
|
|
72
|
+
size={size}
|
|
73
|
+
disabled={disabled || isLoading}
|
|
74
|
+
onClick={onProceed}
|
|
75
|
+
className={cn(
|
|
76
|
+
"relative min-w-[140px] overflow-hidden group transition-all duration-300",
|
|
77
|
+
"bg-purple500 hover:bg-purple600 text-white font-semibold rounded-full px-6 py-2.5 shadow-md hover:shadow-lg",
|
|
78
|
+
variant === "outline" &&
|
|
79
|
+
"bg-transparent border-purple500 text-purple500 hover:bg-purple50",
|
|
80
|
+
isLoading && "opacity-80",
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<div className="relative z-10 flex items-center justify-center gap-2">
|
|
84
|
+
{isLoading ? (
|
|
85
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
86
|
+
) : (
|
|
87
|
+
<>
|
|
88
|
+
<span>{label}</span>
|
|
89
|
+
{showCountdown &&
|
|
90
|
+
countdownProp &&
|
|
91
|
+
countdownProp > 0 &&
|
|
92
|
+
!isLoading && (
|
|
93
|
+
<span className="opacity-70 text-xs bg-white/20 px-1.5 py-0.5 rounded-md tabular-nums min-w-[24px]">
|
|
94
|
+
{timeLeft}s
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Progress Background Overlay (Optional) */}
|
|
103
|
+
{!isPaused && countdownProp && countdownProp > 0 && (
|
|
104
|
+
<div
|
|
105
|
+
className="absolute bottom-0 left-0 h-0.5 bg-white/30 transition-all duration-1000 ease-linear"
|
|
106
|
+
style={{ width: `${progress}%` }}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
109
|
+
</Button>
|
|
110
|
+
|
|
111
|
+
{countdownProp !== undefined && countdownProp > 0 && onPause && (
|
|
112
|
+
<Button
|
|
113
|
+
variant="ghost"
|
|
114
|
+
size="icon"
|
|
115
|
+
onClick={onPause}
|
|
116
|
+
className="h-10 w-10 rounded-full hover:bg-gray-100 text-gray-500"
|
|
117
|
+
title={isPaused ? "Resume auto-proceed" : "Pause auto-proceed"}
|
|
118
|
+
>
|
|
119
|
+
{isPaused ? (
|
|
120
|
+
<Play className="h-4 w-4 fill-current" />
|
|
121
|
+
) : (
|
|
122
|
+
<Pause className="h-4 w-4 fill-current" />
|
|
123
|
+
)}
|
|
124
|
+
</Button>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{secondaryLabel && (
|
|
128
|
+
<span className="text-sm text-gray-500 font-medium italic">
|
|
129
|
+
{secondaryLabel}
|
|
130
|
+
</span>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
ActionButton.displayName = "ActionButton";
|