# This is the 1st commit message: first commit # This is the commit message #2: fixed cache
1984 lines
56 KiB
Text
1984 lines
56 KiB
Text
================================================================
|
|
Repopack Output File
|
|
================================================================
|
|
|
|
This file was generated by Repopack on: 2024-08-11T23:27:27.749Z
|
|
|
|
Purpose:
|
|
--------
|
|
This file contains a packed representation of the entire repository's contents.
|
|
It is designed to be easily consumable by AI systems for analysis, code review,
|
|
or other automated processes.
|
|
|
|
File Format:
|
|
------------
|
|
The content is organized as follows:
|
|
1. This header section
|
|
2. Repository structure
|
|
3. Multiple file entries, each consisting of:
|
|
a. A separator line (================)
|
|
b. The file path (File: path/to/file)
|
|
c. Another separator line
|
|
d. The full contents of the file
|
|
e. A blank line
|
|
|
|
Usage Guidelines:
|
|
-----------------
|
|
1. This file should be treated as read-only. Any changes should be made to the
|
|
original repository files, not this packed version.
|
|
2. When processing this file, use the separators and "File:" markers to
|
|
distinguish between different files in the repository.
|
|
3. Be aware that this file may contain sensitive information. Handle it with
|
|
the same level of security as you would the original repository.
|
|
|
|
Notes:
|
|
------
|
|
- Some files may have been excluded based on .gitignore rules and Repopack's
|
|
configuration.
|
|
- Binary files are not included in this packed representation. Please refer to
|
|
the Repository Structure section for a complete list of file paths, including
|
|
binary files.
|
|
|
|
|
|
|
|
For more information about Repopack, visit: https://github.com/yamadashy/repopack
|
|
|
|
================================================================
|
|
Repository Structure
|
|
================================================================
|
|
app/
|
|
sons/
|
|
[id]/
|
|
page.tsx
|
|
new/
|
|
page.tsx
|
|
page.tsx
|
|
favicon.ico
|
|
layout.tsx
|
|
page.tsx
|
|
components/
|
|
ActionForm.tsx
|
|
CampaignActionFields.tsx
|
|
Dashboard.tsx
|
|
layout.tsx
|
|
ManageSubscriberActionFields.tsx
|
|
SonCreationForm.tsx
|
|
SonDetailsForm.tsx
|
|
SonList.tsx
|
|
hooks/
|
|
useCustomToast.ts
|
|
useLists.ts
|
|
useSon.ts
|
|
useSons.ts
|
|
useTemplates.ts
|
|
lib/
|
|
api-client.ts
|
|
schemas.ts
|
|
types.ts
|
|
utils.ts
|
|
public/
|
|
next.svg
|
|
vercel.svg
|
|
styles/
|
|
globals.css
|
|
.eslintrc.json
|
|
.gitignore
|
|
components.json
|
|
next.config.mjs
|
|
package.json
|
|
postcss.config.mjs
|
|
README.md
|
|
tailwind.config.ts
|
|
tsconfig.json
|
|
|
|
================================================================
|
|
Repository Files
|
|
================================================================
|
|
|
|
================
|
|
File: app/sons/[id]/page.tsx
|
|
================
|
|
"use client";
|
|
import React, { useEffect } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import { useForm, useFieldArray } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Form } from "@/components/ui/form";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { PlusCircle } from "lucide-react";
|
|
import { useSon } from "@/hooks/useSon";
|
|
import { useSons } from "@/hooks/useSons";
|
|
import { useLists } from "@/hooks/useLists";
|
|
import { useTemplates } from "@/hooks/useTemplates";
|
|
import { useCustomToast } from "@/hooks/useCustomToast";
|
|
import { SonDetailsForm } from "@/components/SonDetailsForm";
|
|
import { ActionForm } from "@/components/ActionForm";
|
|
import { editableSonSchema } from "@/lib/schemas";
|
|
import { EditableSon, Son } from "@/lib/types";
|
|
|
|
// Define a type guard function
|
|
function isValidTrigger(trigger: string): trigger is EditableSon["trigger"] {
|
|
return [
|
|
"member_created",
|
|
"member_deleted",
|
|
"member_updated",
|
|
"post_published",
|
|
"post_scheduled",
|
|
].includes(trigger);
|
|
}
|
|
|
|
export default function SonDetailPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const {
|
|
son,
|
|
loading: sonLoading,
|
|
error: sonError,
|
|
} = useSon(params.id as string);
|
|
const { updateSon } = useSons();
|
|
const { showToast } = useCustomToast();
|
|
const { lists, loading: listsLoading, error: listsError } = useLists();
|
|
const {
|
|
templates,
|
|
loading: templatesLoading,
|
|
error: templatesError,
|
|
} = useTemplates();
|
|
|
|
const form = useForm<EditableSon>({
|
|
resolver: zodResolver(editableSonSchema),
|
|
defaultValues: {
|
|
name: "",
|
|
trigger: "member_created",
|
|
delay: 0,
|
|
actions: [],
|
|
},
|
|
});
|
|
|
|
const { fields, append, remove } = useFieldArray({
|
|
control: form.control,
|
|
name: "actions",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (son) {
|
|
const { name, trigger, delay, actions } = son;
|
|
|
|
// Use the type guard to ensure the trigger is valid
|
|
if (isValidTrigger(trigger)) {
|
|
form.reset({ name, trigger, delay, actions: [] });
|
|
} else {
|
|
console.error(`Invalid trigger value: ${trigger}`);
|
|
// You might want to set a default value or handle this case differently
|
|
form.reset({ name, trigger: "member_created", delay, actions: [] });
|
|
}
|
|
}
|
|
}, [son, form]);
|
|
|
|
if (sonLoading || listsLoading || templatesLoading) {
|
|
return (
|
|
<div className="flex justify-center items-center h-full">Loading...</div>
|
|
);
|
|
}
|
|
|
|
if (sonError || listsError || templatesError) {
|
|
return (
|
|
<div className="text-red-500">
|
|
Error:{" "}
|
|
{sonError?.message || listsError?.message || templatesError?.message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!son) return <div>Son not found</div>;
|
|
|
|
const onSubmit = async (data: EditableSon) => {
|
|
try {
|
|
await updateSon(son.id, data);
|
|
showToast("Success", "Son updated successfully");
|
|
router.push("/sons");
|
|
} catch (error) {
|
|
showToast("Error", "Failed to update Son", "destructive");
|
|
console.error("Failed to update Son:", error);
|
|
}
|
|
};
|
|
console.log("son", son);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<h1 className="text-2xl font-bold">Edit Son: {son.name}</h1>
|
|
<p className="text-sm text-gray-500">
|
|
Created at: {new Date(son.created_at).toLocaleString()}
|
|
</p>
|
|
<p className="text-sm text-gray-500">
|
|
Last updated: {new Date(son.updated_at).toLocaleString()}
|
|
</p>
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
|
<SonDetailsForm form={form} />
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{fields.map((field, index) => (
|
|
<ActionForm
|
|
key={field.id}
|
|
form={form}
|
|
index={index}
|
|
remove={remove}
|
|
lists={lists}
|
|
templates={templates}
|
|
/>
|
|
))}
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={() =>
|
|
append({ type: "send_transactional_email", parameters: {} })
|
|
}
|
|
variant="outline"
|
|
className="w-full"
|
|
>
|
|
<PlusCircle className="mr-2 h-4 w-4" /> Add Action
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Button type="submit" className="w-full">
|
|
Save Changes
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: app/sons/new/page.tsx
|
|
================
|
|
import SonCreationForm from "@/components/SonCreationForm";
|
|
|
|
export default function NewSonPage() {
|
|
return (
|
|
<div>
|
|
<h1 className="text-2xl font-bold mb-4">Create New Son</h1>
|
|
<SonCreationForm />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: app/sons/page.tsx
|
|
================
|
|
"use client";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
import { SonList } from "@/components/SonList";
|
|
|
|
export default function SonListPage() {
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h1 className="text-2xl font-bold">Manage Sons</h1>
|
|
<Link href="/sons/new">
|
|
<Button>Create New Son</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
<SonList />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: app/layout.tsx
|
|
================
|
|
import "@/styles/globals.css";
|
|
import { Inter as FontSans } from "next/font/google";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import Layout from "@/components/layout";
|
|
import { Toaster } from "@/components/ui/toaster";
|
|
|
|
const fontSans = FontSans({
|
|
subsets: ["latin"],
|
|
variable: "--font-sans",
|
|
});
|
|
|
|
export default function RootLayout({
|
|
children,
|
|
}: Readonly<{
|
|
children: React.ReactNode;
|
|
}>) {
|
|
return (
|
|
<html lang="en">
|
|
<body
|
|
className={cn(
|
|
"min-h-screen bg-background font-sans antialiased",
|
|
fontSans.variable
|
|
)}
|
|
>
|
|
<Layout>{children}</Layout>
|
|
<Toaster />
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: app/page.tsx
|
|
================
|
|
import Dashboard from "@/components/Dashboard";
|
|
|
|
export default function HomePage() {
|
|
return <Dashboard />;
|
|
}
|
|
|
|
================
|
|
File: components/ActionForm.tsx
|
|
================
|
|
import React from "react";
|
|
import {
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormControl,
|
|
FormMessage,
|
|
FormDescription,
|
|
} from "@/components/ui/form";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Trash2 } from "lucide-react";
|
|
import { CampaignActionFields } from "./CampaignActionFields";
|
|
import { ManageSubscriberActionFields } from "./ManageSubscriberActionFields";
|
|
import { ActionFormProps } from "@/lib/types";
|
|
|
|
export function ActionForm({
|
|
form,
|
|
index,
|
|
remove,
|
|
lists,
|
|
templates,
|
|
}: ActionFormProps) {
|
|
const actionType = form.watch(`actions.${index}.type`);
|
|
|
|
return (
|
|
<Card className="border border-gray-200">
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">
|
|
Action {index + 1}
|
|
</CardTitle>
|
|
<Button
|
|
type="button"
|
|
onClick={() => remove(index)}
|
|
variant="ghost"
|
|
size="sm"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.type`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Action Type</FormLabel>
|
|
<Select
|
|
onValueChange={(value) => {
|
|
field.onChange(value);
|
|
form.setValue(`actions.${index}.parameters`, {});
|
|
}}
|
|
value={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select an action type" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="send_transactional_email">
|
|
Send Transactional Email
|
|
</SelectItem>
|
|
<SelectItem value="manage_subscriber">
|
|
Manage Subscriber
|
|
</SelectItem>
|
|
<SelectItem value="create_campaign">
|
|
Create Campaign
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{actionType === "create_campaign" && (
|
|
<CampaignActionFields
|
|
form={form}
|
|
index={index}
|
|
lists={lists}
|
|
templates={templates}
|
|
/>
|
|
)}
|
|
|
|
{actionType === "manage_subscriber" && (
|
|
<ManageSubscriberActionFields
|
|
form={form}
|
|
index={index}
|
|
lists={lists}
|
|
/>
|
|
)}
|
|
|
|
{actionType === "send_transactional_email" && (
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Parameters</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
onChange={(e) => {
|
|
try {
|
|
const parsedValue = JSON.parse(e.target.value);
|
|
field.onChange(parsedValue);
|
|
} catch (error) {
|
|
field.onChange(e.target.value);
|
|
}
|
|
}}
|
|
value={
|
|
typeof field.value === "object"
|
|
? JSON.stringify(field.value, null, 2)
|
|
: field.value
|
|
}
|
|
className="font-mono text-sm"
|
|
rows={5}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Enter the parameters for the transactional email as JSON.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/CampaignActionFields.tsx
|
|
================
|
|
import React from "react";
|
|
import {
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormControl,
|
|
FormMessage,
|
|
FormDescription,
|
|
} from "@/components/ui/form";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { X } from "lucide-react";
|
|
import { CampaignActionFieldsProps } from "@/lib/types";
|
|
|
|
export function CampaignActionFields({ form, index, lists, templates }: CampaignActionFieldsProps) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.subject`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Subject</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="Enter campaign subject" />
|
|
</FormControl>
|
|
<FormDescription>
|
|
The subject line for your campaign email.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.lists`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Lists</FormLabel>
|
|
<FormControl>
|
|
<Select
|
|
onValueChange={(value) => {
|
|
const newLists = [...(field.value || []), parseInt(value)];
|
|
field.onChange(newLists);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select lists" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{lists.map((list) => (
|
|
<SelectItem key={list.id} value={list.id.toString()}>
|
|
{list.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{field.value?.map((listId: number) => {
|
|
const list = lists.find((l) => l.id === listId);
|
|
return (
|
|
<Badge key={listId} variant="secondary" className="px-2 py-1">
|
|
{list ? list.name : `List ${listId}`}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="ml-1 h-4 w-4 p-0"
|
|
onClick={() => {
|
|
const newLists = field.value.filter(
|
|
(id: number) => id !== listId
|
|
);
|
|
field.onChange(newLists);
|
|
}}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
<FormDescription>
|
|
Select the lists to send this campaign to.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.template_id`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Template</FormLabel>
|
|
<FormControl>
|
|
<Select
|
|
onValueChange={(value) => field.onChange(parseInt(value))}
|
|
value={field.value?.toString()}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select template" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{templates.map((template) => (
|
|
<SelectItem
|
|
key={template.id}
|
|
value={template.id.toString()}
|
|
>
|
|
{template.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Choose the template for your campaign email.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.tags`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Tags</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} placeholder="Enter comma-separated tags" />
|
|
</FormControl>
|
|
<FormDescription>
|
|
Add tags to categorize your campaign (comma-separated).
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.send_now`}
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
<div className="space-y-0.5">
|
|
<FormLabel className="text-base">Send Now</FormLabel>
|
|
<FormDescription>
|
|
Toggle to send the campaign immediately upon creation.
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/Dashboard.tsx
|
|
================
|
|
"use client";
|
|
import { useEffect, useState } from "react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
} from "recharts";
|
|
import { ChartContainer, ChartConfig } from "@/components/ui/chart";
|
|
|
|
// Mock data - replace with actual API calls in production
|
|
const recentActivity = [
|
|
{
|
|
id: 1,
|
|
action: 'Son "Welcome Email" triggered',
|
|
timestamp: "2024-08-10T10:30:00Z",
|
|
},
|
|
{
|
|
id: 2,
|
|
action: 'New Son "Survey Request" created',
|
|
timestamp: "2024-08-10T09:15:00Z",
|
|
},
|
|
{
|
|
id: 3,
|
|
action: 'Son "Monthly Newsletter" modified',
|
|
timestamp: "2024-08-09T16:45:00Z",
|
|
},
|
|
];
|
|
|
|
const sonStats = [
|
|
{ name: "Welcome Email", executions: 120, success: 115, failure: 5 },
|
|
{ name: "Survey Request", executions: 80, success: 78, failure: 2 },
|
|
{ name: "Monthly Newsletter", executions: 50, success: 50, failure: 0 },
|
|
];
|
|
|
|
const chartConfig = {
|
|
executions: {
|
|
label: "Executions",
|
|
color: "#2563eb",
|
|
},
|
|
success: {
|
|
label: "Success",
|
|
color: "#60a5fa",
|
|
},
|
|
failure: {
|
|
label: "Failure",
|
|
color: "#d1d5db",
|
|
},
|
|
} satisfies ChartConfig;
|
|
|
|
export default function Dashboard() {
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
// Simulate API call
|
|
setTimeout(() => setIsLoading(false), 1000);
|
|
}, []);
|
|
|
|
if (isLoading) {
|
|
return <div>Loading...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Recent Activity</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{recentActivity.map((activity) => (
|
|
<Alert key={activity.id} className="mb-2">
|
|
<AlertTitle>{activity.action}</AlertTitle>
|
|
<AlertDescription>
|
|
{new Date(activity.timestamp).toLocaleString()}
|
|
</AlertDescription>
|
|
</Alert>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Son Performance</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ChartContainer
|
|
config={chartConfig}
|
|
className="min-h-[200px] w-full"
|
|
>
|
|
<BarChart data={sonStats}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="name" />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Bar dataKey="success" fill="var(--color-success)" />
|
|
<Bar dataKey="failure" fill="var(--color-failure)" />
|
|
</BarChart>
|
|
</ChartContainer>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/layout.tsx
|
|
================
|
|
"use client";
|
|
import { useState } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
LayoutDashboard,
|
|
ListPlus,
|
|
ListTree,
|
|
Settings,
|
|
Menu,
|
|
} from "lucide-react";
|
|
|
|
const Sidebar = ({ isOpen }: { isOpen: boolean }) => {
|
|
const pathname = usePathname();
|
|
|
|
const menuItems = [
|
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
|
{ href: "/sons/new", label: "Create Son", icon: ListPlus },
|
|
{ href: "/sons", label: "Manage Sons", icon: ListTree },
|
|
{ href: "/settings", label: "Settings", icon: Settings },
|
|
];
|
|
|
|
return (
|
|
<aside
|
|
className={`bg-gray-800 text-white w-64 min-h-screen p-4 ${
|
|
isOpen ? "" : "hidden"
|
|
} md:block`}
|
|
>
|
|
<nav>
|
|
<ul>
|
|
{menuItems.map((item) => (
|
|
<li key={item.href} className="mb-2">
|
|
<Link href={item.href}>
|
|
<Button
|
|
variant={pathname === item.href ? "secondary" : "ghost"}
|
|
className="w-full justify-start"
|
|
>
|
|
<item.icon className="mr-2 h-4 w-4" />
|
|
{item.label}
|
|
</Button>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="flex min-h-screen">
|
|
<Sidebar isOpen={isSidebarOpen} />
|
|
<div className="flex-1">
|
|
<header className="bg-white shadow p-4 flex justify-between items-center">
|
|
<Button
|
|
variant="ghost"
|
|
className="md:hidden"
|
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
|
>
|
|
<Menu className="h-6 w-6" />
|
|
</Button>
|
|
<h1 className="text-xl font-bold">Ghost-Listmonk Connector</h1>
|
|
</header>
|
|
<main className="p-4 max-w-3xl mx-auto">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/ManageSubscriberActionFields.tsx
|
|
================
|
|
import React from "react";
|
|
import {
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormControl,
|
|
FormMessage,
|
|
FormDescription,
|
|
} from "@/components/ui/form";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { X } from "lucide-react";
|
|
import { ManageSubscriberActionFieldsProps } from "@/lib/types";
|
|
|
|
export function ManageSubscriberActionFields({ form, index, lists } : ManageSubscriberActionFieldsProps) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.lists`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Lists</FormLabel>
|
|
<FormControl>
|
|
<Select
|
|
onValueChange={(value) => {
|
|
const newLists = [...(field.value || []), parseInt(value)];
|
|
field.onChange(newLists);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select lists" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{lists.map((list) => (
|
|
<SelectItem key={list.id} value={list.id.toString()}>
|
|
{list.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{field.value?.map((listId: number) => {
|
|
const list = lists.find((l) => l.id === listId);
|
|
return (
|
|
<Badge key={listId} variant="secondary" className="px-2 py-1">
|
|
{list ? list.name : `List ${listId}`}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="ml-1 h-4 w-4 p-0"
|
|
onClick={() => {
|
|
const newLists = field.value.filter(
|
|
(id: number) => id !== listId
|
|
);
|
|
field.onChange(newLists);
|
|
}}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
<FormDescription>
|
|
Default lists to add this user on creation.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
{/* Add more fields specific to manage_subscriber action if needed */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/SonCreationForm.tsx
|
|
================
|
|
"use client";
|
|
import React from "react";
|
|
import { useForm, useFieldArray } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import * as z from "zod";
|
|
import { useRouter } from "next/navigation";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Form } from "@/components/ui/form";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { PlusCircle } from "lucide-react";
|
|
import { useSons } from "@/hooks/useSons";
|
|
import { useLists } from "@/hooks/useLists";
|
|
import { useTemplates } from "@/hooks/useTemplates";
|
|
import { useCustomToast } from "@/hooks/useCustomToast";
|
|
import { SonDetailsForm } from "./SonDetailsForm";
|
|
import { ActionForm } from "./ActionForm";
|
|
|
|
// Import or define your schema here
|
|
import { sonSchema } from "@/lib/schemas";
|
|
|
|
type SonFormValues = z.infer<typeof sonSchema>;
|
|
|
|
export default function SonCreationForm() {
|
|
const { createSon } = useSons();
|
|
const { showToast } = useCustomToast();
|
|
const router = useRouter();
|
|
const { lists, loading: listsLoading, error: listsError } = useLists();
|
|
const {
|
|
templates,
|
|
loading: templatesLoading,
|
|
error: templatesError,
|
|
} = useTemplates();
|
|
|
|
const form = useForm<SonFormValues>({
|
|
resolver: zodResolver(sonSchema),
|
|
defaultValues: {
|
|
name: "",
|
|
trigger: "member_created",
|
|
delay: 0,
|
|
actions: [
|
|
{
|
|
type: "send_transactional_email",
|
|
parameters: {},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const { fields, append, remove } = useFieldArray({
|
|
control: form.control,
|
|
name: "actions",
|
|
});
|
|
|
|
const onSubmit = async (data: SonFormValues) => {
|
|
try {
|
|
await createSon(data);
|
|
showToast("Success", "Son created successfully");
|
|
router.push("/sons");
|
|
} catch (error) {
|
|
showToast("Error", "Failed to create Son", "destructive");
|
|
console.error("Failed to create Son:", error);
|
|
}
|
|
};
|
|
|
|
if (listsLoading || templatesLoading) {
|
|
return (
|
|
<div className="flex justify-center items-center h-full">Loading...</div>
|
|
);
|
|
}
|
|
|
|
if (listsError || templatesError) {
|
|
return (
|
|
<div className="text-red-500">
|
|
Error: {listsError?.message || templatesError?.message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
|
<SonDetailsForm form={form} />
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{fields.map((field, index) => (
|
|
<ActionForm
|
|
key={field.id}
|
|
form={form}
|
|
index={index}
|
|
remove={remove}
|
|
lists={lists}
|
|
templates={templates}
|
|
/>
|
|
))}
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={() =>
|
|
append({ type: "send_transactional_email", parameters: {} })
|
|
}
|
|
variant="outline"
|
|
className="w-full"
|
|
>
|
|
<PlusCircle className="mr-2 h-4 w-4" /> Add Action
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Button type="submit" className="w-full">
|
|
Create Son
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/SonDetailsForm.tsx
|
|
================
|
|
import React from "react";
|
|
import {
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormControl,
|
|
FormMessage,
|
|
FormDescription,
|
|
} from "@/components/ui/form";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { SonDetailsFormProps } from "@/lib/types";
|
|
|
|
export function SonDetailsForm({ form }: SonDetailsFormProps) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Son Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="name"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Son Name</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder="Enter Son name" {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
Give your Son a unique and descriptive name.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="trigger"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Trigger</FormLabel>
|
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a trigger" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="member_created">Member Created</SelectItem>
|
|
<SelectItem value="member_deleted">Member Deleted</SelectItem>
|
|
<SelectItem value="member_updated">Member Updated</SelectItem>
|
|
<SelectItem value="post_published">Post Published</SelectItem>
|
|
<SelectItem value="post_scheduled">Post Scheduled</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
Choose the event that will trigger this Son.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="delay"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Delay (minutes)</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
{...field}
|
|
onChange={(e) => field.onChange(parseInt(e.target.value))}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Set a delay before the Son executes its actions.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/SonList.tsx
|
|
================
|
|
"use client";
|
|
import React from "react";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Pencil, Trash2 } from "lucide-react";
|
|
import { useSons } from "@/hooks/useSons";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
|
|
export function SonList() {
|
|
const { sons, loading, error, deleteSon } = useSons();
|
|
|
|
if (loading) {
|
|
return <div>Loading sons...</div>;
|
|
}
|
|
|
|
if (error) {
|
|
return <div>Error: {error.message}</div>;
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Your Sons</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{sons.length === 0 && <div>No sons found. Create your first son!</div>}
|
|
|
|
{sons.length > 0 && (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Trigger</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sons.map((son) => (
|
|
<TableRow key={son.id}>
|
|
<TableCell>{son.name}</TableCell>
|
|
<TableCell>{son.trigger}</TableCell>
|
|
<TableCell>
|
|
<div className="flex space-x-2">
|
|
<Link href={`/sons/${son.id}`}>
|
|
<Button variant="outline" size="icon">
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="outline" size="icon">
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This action cannot be undone. This will
|
|
permanently delete the Son.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => deleteSon(son.id)}
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: hooks/useCustomToast.ts
|
|
================
|
|
import { useToast } from "@/components/ui/use-toast"
|
|
|
|
export function useCustomToast() {
|
|
const { toast } = useToast()
|
|
|
|
const showToast = (title: string, description: string, variant: "default" | "destructive" = "default") => {
|
|
toast({
|
|
title,
|
|
description,
|
|
variant,
|
|
})
|
|
}
|
|
|
|
return { showToast }
|
|
}
|
|
|
|
================
|
|
File: hooks/useLists.ts
|
|
================
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { ListmonkList } from '@/lib/types';
|
|
|
|
|
|
export function useLists() {
|
|
const [lists, setLists] = useState<ListmonkList[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchLists = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get<{ data: ListmonkList[] }>('/lists');
|
|
setLists(response.data.data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchLists();
|
|
}, [fetchLists]);
|
|
|
|
return { lists, loading, error, fetchLists };
|
|
}
|
|
|
|
================
|
|
File: hooks/useSon.ts
|
|
================
|
|
// src/hooks/useSon.ts
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { Son } from '@/lib/types';
|
|
|
|
export function useSon(id: string) {
|
|
const [son, setSon] = useState<Son | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchSon = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get<Son>(`/sons/${id}`);
|
|
setSon(response.data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
fetchSon();
|
|
}, [fetchSon]);
|
|
|
|
return { son, loading, error, fetchSon };
|
|
}
|
|
|
|
================
|
|
File: hooks/useSons.ts
|
|
================
|
|
// src/hooks/useSons.ts
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { Son } from '@/lib/types';
|
|
|
|
|
|
export function useSons() {
|
|
const [sons, setSons] = useState<Son[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchSons = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get<Son[]>('/sons');
|
|
setSons(response.data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const createSon = useCallback(async (sonData: Omit<Son, 'id' | 'created_at' | 'updated_at'>) => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.post<Son>('/sons', sonData);
|
|
setSons(prevSons => [...prevSons, response.data]);
|
|
setError(null);
|
|
return response.data;
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
throw err;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const updateSon = useCallback(async (id: string, sonData: Partial<Son>) => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.put<Son>(`/sons/${id}`, sonData);
|
|
setSons(prevSons => prevSons.map(son => son.id === id ? response.data : son));
|
|
setError(null);
|
|
return response.data;
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
throw err;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const deleteSon = useCallback(async (id: string) => {
|
|
setLoading(true);
|
|
try {
|
|
await apiClient.delete(`/sons/${id}`);
|
|
setSons(prevSons => prevSons.filter(son => son.id !== id));
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
throw err;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchSons();
|
|
}, [fetchSons]);
|
|
|
|
return { sons, loading, error, fetchSons, createSon, updateSon, deleteSon };
|
|
}
|
|
|
|
================
|
|
File: hooks/useTemplates.ts
|
|
================
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { ListmonkTemplate } from '@/lib/types';
|
|
|
|
|
|
|
|
export function useTemplates() {
|
|
const [templates, setTemplates] = useState<ListmonkTemplate[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchTemplates = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get<{ data: ListmonkTemplate[] }>('/templates');
|
|
setTemplates(response.data.data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchTemplates();
|
|
}, [fetchTemplates]);
|
|
|
|
return { templates, loading, error, fetchTemplates };
|
|
}
|
|
|
|
================
|
|
File: lib/api-client.ts
|
|
================
|
|
// src/lib/api-client.ts
|
|
import axios from 'axios';
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8808';
|
|
const API_KEY = process.env.NEXT_PUBLIC_API_KEY || "your-api-key";
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: API_BASE_URL.replace(/\/$/, ''), // Remove trailing slash if present
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${API_KEY}`,
|
|
},
|
|
withCredentials: true,
|
|
});
|
|
|
|
================
|
|
File: lib/schemas.ts
|
|
================
|
|
import * as z from 'zod';
|
|
|
|
// Define the schema for action parameters
|
|
const actionParametersSchema = z.object({
|
|
subject: z.string().optional(),
|
|
lists: z.array(z.number()).optional(),
|
|
template_id: z.number().optional(),
|
|
tags: z.string().optional(),
|
|
send_now: z.boolean().optional(),
|
|
}).strict().or(z.record(z.any())); // Allow any other properties for flexibility
|
|
|
|
|
|
|
|
// Define the schema for a single action
|
|
const actionSchema = z.object({
|
|
type: z.enum(['send_transactional_email', 'manage_subscriber', 'create_campaign']),
|
|
parameters: actionParametersSchema,
|
|
});
|
|
|
|
// Define the schema for the entire Son object
|
|
export const sonSchema = z.object({
|
|
id: z.string().optional(), // Optional because it might not be present when creating a new Son
|
|
name: z.string().min(1, 'Name is required'),
|
|
trigger: z.enum([
|
|
'member_created',
|
|
'member_deleted',
|
|
'member_updated',
|
|
'post_published',
|
|
'post_scheduled',
|
|
]),
|
|
delay: z.number().min(0, 'Delay must be a positive number'),
|
|
actions: z.array(actionSchema).min(1, 'At least one action is required'),
|
|
created_at: z.date().optional(),
|
|
updated_at: z.date().optional(),
|
|
});
|
|
|
|
export const editableSonSchema = z.object({
|
|
name: z.string().min(1, 'Name is required'),
|
|
trigger: z.enum([
|
|
'member_created',
|
|
'member_deleted',
|
|
'member_updated',
|
|
'post_published',
|
|
'post_scheduled',
|
|
]),
|
|
delay: z.number().min(0, 'Delay must be a positive number'),
|
|
actions: z.array(z.object({
|
|
type: z.enum(['send_transactional_email', 'manage_subscriber', 'create_campaign']),
|
|
parameters: z.record(z.any()),
|
|
})),
|
|
});
|
|
|
|
|
|
// Define a schema for creating a new Son (without id, createdAt, and updatedAt)
|
|
export const createSonSchema = sonSchema.omit({ id: true, created_at: true, updated_at: true });
|
|
|
|
// Define a schema for updating an existing Son
|
|
export const updateSonSchema = sonSchema.partial().extend({
|
|
id: z.string(),
|
|
});
|
|
|
|
// Define types for create and update operations
|
|
export type CreateSonInput = z.infer<typeof createSonSchema>;
|
|
export type UpdateSonInput = z.infer<typeof updateSonSchema>;
|
|
|
|
================
|
|
File: lib/types.ts
|
|
================
|
|
import { UseFormReturn, FieldValues } from 'react-hook-form';
|
|
import { editableSonSchema } from '@/lib/schemas'; // Assuming this is where your Son type is defined
|
|
import { z } from 'zod';
|
|
// Define a type for the Son object based on the schema
|
|
export interface Son extends z.infer<typeof editableSonSchema> {
|
|
id: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
// Type for editable Son fields
|
|
export type EditableSon = z.infer<typeof editableSonSchema>;
|
|
|
|
export interface ListmonkList {
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
optin: string;
|
|
tags: string[];
|
|
}
|
|
|
|
export interface ListmonkTemplate {
|
|
id: number;
|
|
name: string;
|
|
type: string;
|
|
is_default: boolean;
|
|
}
|
|
// Props for CampaignActionFields
|
|
export interface CampaignActionFieldsProps {
|
|
form: UseFormReturn<EditableSon>;
|
|
index: number;
|
|
lists: ListmonkList[];
|
|
templates: ListmonkTemplate[];
|
|
}
|
|
|
|
// Props for ManageSubscriberActionFields
|
|
export interface ManageSubscriberActionFieldsProps {
|
|
form: UseFormReturn<EditableSon>;
|
|
index: number;
|
|
lists: ListmonkList[];
|
|
}
|
|
|
|
// Props for SonDetailsForm
|
|
export interface SonDetailsFormProps {
|
|
form: UseFormReturn<EditableSon>;
|
|
}
|
|
|
|
export interface ActionFormProps {
|
|
form: UseFormReturn<EditableSon>;
|
|
index: number;
|
|
remove: (index: number) => void;
|
|
lists: ListmonkList[];
|
|
templates: ListmonkTemplate[];
|
|
}
|
|
|
|
================
|
|
File: lib/utils.ts
|
|
================
|
|
import { type ClassValue, clsx } from "clsx"
|
|
import { twMerge } from "tailwind-merge"
|
|
|
|
export function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs))
|
|
}
|
|
|
|
================
|
|
File: public/next.svg
|
|
================
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
|
|
|
================
|
|
File: public/vercel.svg
|
|
================
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
|
|
|
================
|
|
File: styles/globals.css
|
|
================
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
@layer base {
|
|
:root {
|
|
--background: 0 0% 100%;
|
|
--foreground: 222.2 84% 4.9%;
|
|
--card: 0 0% 100%;
|
|
--card-foreground: 222.2 84% 4.9%;
|
|
--popover: 0 0% 100%;
|
|
--popover-foreground: 222.2 84% 4.9%;
|
|
--primary: 222.2 47.4% 11.2%;
|
|
--primary-foreground: 210 40% 98%;
|
|
--secondary: 210 40% 96.1%;
|
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
--muted: 210 40% 96.1%;
|
|
--muted-foreground: 215.4 16.3% 46.9%;
|
|
--accent: 210 40% 96.1%;
|
|
--accent-foreground: 222.2 47.4% 11.2%;
|
|
--destructive: 0 84.2% 60.2%;
|
|
--destructive-foreground: 210 40% 98%;
|
|
--border: 214.3 31.8% 91.4%;
|
|
--input: 214.3 31.8% 91.4%;
|
|
--ring: 222.2 84% 4.9%;
|
|
--radius: 0.5rem;
|
|
--chart-1: 12 76% 61%;
|
|
--chart-2: 173 58% 39%;
|
|
--chart-3: 197 37% 24%;
|
|
--chart-4: 43 74% 66%;
|
|
--chart-5: 27 87% 67%;
|
|
}
|
|
|
|
.dark {
|
|
--background: 222.2 84% 4.9%;
|
|
--foreground: 210 40% 98%;
|
|
--card: 222.2 84% 4.9%;
|
|
--card-foreground: 210 40% 98%;
|
|
--popover: 222.2 84% 4.9%;
|
|
--popover-foreground: 210 40% 98%;
|
|
--primary: 210 40% 98%;
|
|
--primary-foreground: 222.2 47.4% 11.2%;
|
|
--secondary: 217.2 32.6% 17.5%;
|
|
--secondary-foreground: 210 40% 98%;
|
|
--muted: 217.2 32.6% 17.5%;
|
|
--muted-foreground: 215 20.2% 65.1%;
|
|
--accent: 217.2 32.6% 17.5%;
|
|
--accent-foreground: 210 40% 98%;
|
|
--destructive: 0 62.8% 30.6%;
|
|
--destructive-foreground: 210 40% 98%;
|
|
--border: 217.2 32.6% 17.5%;
|
|
--input: 217.2 32.6% 17.5%;
|
|
--ring: 212.7 26.8% 83.9%;
|
|
--chart-1: 220 70% 50%;
|
|
--chart-2: 160 60% 45%;
|
|
--chart-3: 30 80% 55%;
|
|
--chart-4: 280 65% 60%;
|
|
--chart-5: 340 75% 55%;
|
|
}
|
|
}
|
|
|
|
@layer base {
|
|
* {
|
|
@apply border-border;
|
|
}
|
|
body {
|
|
@apply bg-background text-foreground;
|
|
}
|
|
}
|
|
|
|
================
|
|
File: .eslintrc.json
|
|
================
|
|
{
|
|
"extends": "next/core-web-vitals"
|
|
}
|
|
|
|
================
|
|
File: .gitignore
|
|
================
|
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
|
|
# dependencies
|
|
/node_modules
|
|
/.pnp
|
|
.pnp.js
|
|
.yarn/install-state.gz
|
|
|
|
# testing
|
|
/coverage
|
|
|
|
# next.js
|
|
/.next/
|
|
/out/
|
|
|
|
# production
|
|
/build
|
|
|
|
# misc
|
|
.DS_Store
|
|
*.pem
|
|
|
|
# debug
|
|
npm-debug.log*
|
|
yarn-debug.log*
|
|
yarn-error.log*
|
|
|
|
# local env files
|
|
.env*.local
|
|
|
|
# vercel
|
|
.vercel
|
|
|
|
# typescript
|
|
*.tsbuildinfo
|
|
next-env.d.ts
|
|
|
|
================
|
|
File: components.json
|
|
================
|
|
{
|
|
"$schema": "https://ui.shadcn.com/schema.json",
|
|
"style": "default",
|
|
"rsc": true,
|
|
"tsx": true,
|
|
"tailwind": {
|
|
"config": "tailwind.config.ts",
|
|
"css": "app/globals.css",
|
|
"baseColor": "slate",
|
|
"cssVariables": true,
|
|
"prefix": ""
|
|
},
|
|
"aliases": {
|
|
"components": "@/components",
|
|
"utils": "@/lib/utils"
|
|
}
|
|
}
|
|
|
|
================
|
|
File: next.config.mjs
|
|
================
|
|
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {};
|
|
|
|
export default nextConfig;
|
|
|
|
================
|
|
File: package.json
|
|
================
|
|
{
|
|
"name": "ghost-listmonk-ui",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"scripts": {
|
|
"dev": "next dev",
|
|
"build": "next build",
|
|
"start": "next start",
|
|
"lint": "next lint"
|
|
},
|
|
"dependencies": {
|
|
"@hookform/resolvers": "^3.9.0",
|
|
"@radix-ui/react-alert-dialog": "^1.1.1",
|
|
"@radix-ui/react-label": "^2.1.0",
|
|
"@radix-ui/react-select": "^2.1.1",
|
|
"@radix-ui/react-separator": "^1.1.0",
|
|
"@radix-ui/react-slot": "^1.1.0",
|
|
"@radix-ui/react-switch": "^1.1.0",
|
|
"@radix-ui/react-toast": "^1.2.1",
|
|
"axios": "^1.7.3",
|
|
"class-variance-authority": "^0.7.0",
|
|
"clsx": "^2.1.1",
|
|
"lucide-react": "^0.427.0",
|
|
"next": "14.2.5",
|
|
"react": "^18",
|
|
"react-dom": "^18",
|
|
"react-hook-form": "^7.52.2",
|
|
"recharts": "^2.12.7",
|
|
"tailwind-merge": "^2.4.0",
|
|
"tailwindcss-animate": "^1.0.7",
|
|
"zod": "^3.23.8"
|
|
},
|
|
"devDependencies": {
|
|
"@types/node": "^20",
|
|
"@types/react": "^18",
|
|
"@types/react-dom": "^18",
|
|
"eslint": "^8",
|
|
"eslint-config-next": "14.2.5",
|
|
"postcss": "^8",
|
|
"tailwindcss": "^3.4.1",
|
|
"typescript": "^5"
|
|
}
|
|
}
|
|
|
|
================
|
|
File: postcss.config.mjs
|
|
================
|
|
/** @type {import('postcss-load-config').Config} */
|
|
const config = {
|
|
plugins: {
|
|
tailwindcss: {},
|
|
},
|
|
};
|
|
|
|
export default config;
|
|
|
|
================
|
|
File: README.md
|
|
================
|
|
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
|
|
|
## Getting Started
|
|
|
|
First, run the development server:
|
|
|
|
```bash
|
|
npm run dev
|
|
# or
|
|
yarn dev
|
|
# or
|
|
pnpm dev
|
|
# or
|
|
bun dev
|
|
```
|
|
|
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
|
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
|
|
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
|
|
|
## Learn More
|
|
|
|
To learn more about Next.js, take a look at the following resources:
|
|
|
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
|
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
|
|
|
## Deploy on Vercel
|
|
|
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
|
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
|
|
|
================
|
|
File: tailwind.config.ts
|
|
================
|
|
import type { Config } from "tailwindcss"
|
|
|
|
const config = {
|
|
darkMode: ["class"],
|
|
content: [
|
|
'./pages/**/*.{ts,tsx}',
|
|
'./components/**/*.{ts,tsx}',
|
|
'./app/**/*.{ts,tsx}',
|
|
'./src/**/*.{ts,tsx}',
|
|
],
|
|
prefix: "",
|
|
theme: {
|
|
container: {
|
|
center: true,
|
|
padding: "2rem",
|
|
screens: {
|
|
"2xl": "1400px",
|
|
},
|
|
},
|
|
extend: {
|
|
colors: {
|
|
border: "hsl(var(--border))",
|
|
input: "hsl(var(--input))",
|
|
ring: "hsl(var(--ring))",
|
|
background: "hsl(var(--background))",
|
|
foreground: "hsl(var(--foreground))",
|
|
primary: {
|
|
DEFAULT: "hsl(var(--primary))",
|
|
foreground: "hsl(var(--primary-foreground))",
|
|
},
|
|
secondary: {
|
|
DEFAULT: "hsl(var(--secondary))",
|
|
foreground: "hsl(var(--secondary-foreground))",
|
|
},
|
|
destructive: {
|
|
DEFAULT: "hsl(var(--destructive))",
|
|
foreground: "hsl(var(--destructive-foreground))",
|
|
},
|
|
muted: {
|
|
DEFAULT: "hsl(var(--muted))",
|
|
foreground: "hsl(var(--muted-foreground))",
|
|
},
|
|
accent: {
|
|
DEFAULT: "hsl(var(--accent))",
|
|
foreground: "hsl(var(--accent-foreground))",
|
|
},
|
|
popover: {
|
|
DEFAULT: "hsl(var(--popover))",
|
|
foreground: "hsl(var(--popover-foreground))",
|
|
},
|
|
card: {
|
|
DEFAULT: "hsl(var(--card))",
|
|
foreground: "hsl(var(--card-foreground))",
|
|
},
|
|
},
|
|
borderRadius: {
|
|
lg: "var(--radius)",
|
|
md: "calc(var(--radius) - 2px)",
|
|
sm: "calc(var(--radius) - 4px)",
|
|
},
|
|
keyframes: {
|
|
"accordion-down": {
|
|
from: { height: "0" },
|
|
to: { height: "var(--radix-accordion-content-height)" },
|
|
},
|
|
"accordion-up": {
|
|
from: { height: "var(--radix-accordion-content-height)" },
|
|
to: { height: "0" },
|
|
},
|
|
},
|
|
animation: {
|
|
"accordion-down": "accordion-down 0.2s ease-out",
|
|
"accordion-up": "accordion-up 0.2s ease-out",
|
|
},
|
|
},
|
|
},
|
|
plugins: [require("tailwindcss-animate")],
|
|
} satisfies Config
|
|
|
|
export default config
|
|
|
|
================
|
|
File: tsconfig.json
|
|
================
|
|
{
|
|
"compilerOptions": {
|
|
"lib": ["dom", "dom.iterable", "esnext"],
|
|
"allowJs": true,
|
|
"skipLibCheck": true,
|
|
"strict": true,
|
|
"noEmit": true,
|
|
"esModuleInterop": true,
|
|
"module": "esnext",
|
|
"moduleResolution": "bundler",
|
|
"resolveJsonModule": true,
|
|
"isolatedModules": true,
|
|
"jsx": "preserve",
|
|
"incremental": true,
|
|
"plugins": [
|
|
{
|
|
"name": "next"
|
|
}
|
|
],
|
|
"paths": {
|
|
"@/*": ["./*"]
|
|
}
|
|
},
|
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
"exclude": ["node_modules"]
|
|
}
|