fixed cache Refactoring from app router to page router Refactoring from app router to page router Add authentication with JWT base ui done protect the dashboard added transitions styling type trick added cleanup Add enable-disable logic add transactional emails Improvements on delay and frontend added description on select implement auth with magic link migrate to mariadb webhook signature working Adding async processing with queues Implemented webhook reply functionality Add son execution logs update status logic Add recent activity monitoring to sons show son performance in dashboard add readme implement listmonk connector listmonk-connector-v1 Create CODE_OF_CONDUCT.md Create LICENSE
3373 lines
96 KiB
Text
3373 lines
96 KiB
Text
================================================================
|
|
Repopack Output File
|
|
================================================================
|
|
|
|
This file was generated by Repopack on: 2024-08-19T13:56:35.510Z
|
|
|
|
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
|
|
================================================================
|
|
components/
|
|
ActionForm.tsx
|
|
AuthButton.tsx
|
|
CampaignActionFields.tsx
|
|
ContextWrapper.tsx
|
|
Dashboard.tsx
|
|
DashboardSKeleton.tsx
|
|
layout.tsx
|
|
ManageSubscriberActionFields.tsx
|
|
MinimalLayout.tsx
|
|
ProtectedRoute.tsx
|
|
Sidebar.tsx
|
|
SonCreationForm.tsx
|
|
SonDetailsForm.tsx
|
|
SonList.tsx
|
|
SonListSkeleton.tsx
|
|
TransactionalEmailActionFields.tsx
|
|
WebhookInfo.tsx
|
|
contexts/
|
|
AuthContext.tsx
|
|
ListContext.tsx
|
|
SonContext.tsx
|
|
TemplateContext.tsx
|
|
hooks/
|
|
useCustomToast.ts
|
|
useInitialData.ts
|
|
useLists.ts
|
|
useSon.ts
|
|
useSons.ts
|
|
useTemplates.ts
|
|
useWebhook.ts
|
|
lib/
|
|
api-client.ts
|
|
schemas.ts
|
|
types.ts
|
|
utils.ts
|
|
pages/
|
|
auth/
|
|
verify.tsx
|
|
sons/
|
|
[id].tsx
|
|
index.tsx
|
|
new.tsx
|
|
_app.tsx
|
|
_document.tsx
|
|
index.tsx
|
|
login.tsx
|
|
settings.tsx
|
|
public/
|
|
fonts/
|
|
inter-var-latin.woff2
|
|
favicon.ico
|
|
next.svg
|
|
vercel.svg
|
|
styles/
|
|
globals.css
|
|
types/
|
|
next-auth.d.ts
|
|
.eslintrc.json
|
|
.gitignore
|
|
components.json
|
|
next.config.mjs
|
|
package.json
|
|
postcss.config.mjs
|
|
README.md
|
|
tailwind.config.ts
|
|
tsconfig.json
|
|
|
|
================================================================
|
|
Repository Files
|
|
================================================================
|
|
|
|
================
|
|
File: components/ActionForm.tsx
|
|
================
|
|
import React from "react";
|
|
import { UseFormReturn } from "react-hook-form";
|
|
import {
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormControl,
|
|
FormMessage,
|
|
FormDescription,
|
|
} from "@/components/ui/form";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
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 { TransactionalEmailActionFields } from "./TransactionalEmailActionFields";
|
|
import { ListmonkList, ListmonkTemplate } from "@/lib/types";
|
|
|
|
interface ActionFormProps {
|
|
form: UseFormReturn<any>;
|
|
index: number;
|
|
remove: (index: number) => void;
|
|
lists: ListmonkList[];
|
|
templates: ListmonkTemplate[];
|
|
}
|
|
|
|
export function ActionForm({
|
|
form,
|
|
index,
|
|
remove,
|
|
lists,
|
|
templates,
|
|
}: ActionFormProps) {
|
|
const actionType: string = form.watch(`actions.${index}.type`);
|
|
const trigger: string = form.watch("trigger");
|
|
|
|
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: string) => {
|
|
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>
|
|
{actionType === "manage_subscriber" && (
|
|
<FormDescription>
|
|
<p>
|
|
This action will automatically create the subscriber on
|
|
listmonk if he doesn't exist already.
|
|
</p>
|
|
Subscribers will be automatically added or removed to lists
|
|
with the same name as the newsletters on Ghost.
|
|
</FormDescription>
|
|
)}
|
|
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{actionType === "create_campaign" && (
|
|
<CampaignActionFields
|
|
form={form}
|
|
index={index}
|
|
lists={lists}
|
|
templates={templates}
|
|
/>
|
|
)}
|
|
|
|
{actionType === "manage_subscriber" && trigger === "member_created" && (
|
|
<ManageSubscriberActionFields
|
|
form={form}
|
|
index={index}
|
|
lists={lists}
|
|
/>
|
|
)}
|
|
|
|
{actionType === "send_transactional_email" && (
|
|
<TransactionalEmailActionFields
|
|
form={form}
|
|
index={index}
|
|
templates={templates}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/AuthButton.tsx
|
|
================
|
|
import { useAuthContext } from "@/contexts/AuthContext";
|
|
import { Button } from "@/components/ui/button";
|
|
import Link from "next/link";
|
|
import { LogIn, LogOut } from "lucide-react";
|
|
import { useRouter } from "next/router";
|
|
|
|
export const AuthButton = () => {
|
|
const { user, logout } = useAuthContext();
|
|
const router = useRouter();
|
|
|
|
const handleLogout = async () => {
|
|
await logout();
|
|
router.push("/login");
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{user ? (
|
|
<Button onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
Logout
|
|
</Button>
|
|
) : (
|
|
<Link href="/login">
|
|
<Button>
|
|
<LogIn className="mr-2 h-4 w-4" />
|
|
Login
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
================
|
|
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 { Textarea } from "@/components/ui/textarea";
|
|
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.type`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Campaign Type</FormLabel>
|
|
<Select onValueChange={field.onChange} value={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select campaign type" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="regular">Regular</SelectItem>
|
|
<SelectItem value="optin">Opt-in</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
Choose the type of campaign you want to create.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.content_type`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Content Type</FormLabel>
|
|
<Select onValueChange={field.onChange} value={field.value}>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select content type" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value="richtext">Rich Text</SelectItem>
|
|
<SelectItem value="html">HTML</SelectItem>
|
|
<SelectItem value="markdown">Markdown</SelectItem>
|
|
<SelectItem value="plain">Plain Text</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
Choose the content type for your campaign.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.body`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Campaign Body</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
placeholder="Enter your campaign content here"
|
|
rows={10}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
The main content of your campaign. Use the format specified in the
|
|
Content Type field.
|
|
</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/ContextWrapper.tsx
|
|
================
|
|
import { SonProvider } from "@/contexts/SonContext";
|
|
import { ListProvider } from "@/contexts/ListContext";
|
|
import { TemplateProvider } from "@/contexts/TemplateContext";
|
|
|
|
export default function ContextWrapper({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<SonProvider>
|
|
<ListProvider>
|
|
<TemplateProvider>{children}</TemplateProvider>
|
|
</ListProvider>
|
|
</SonProvider>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/Dashboard.tsx
|
|
================
|
|
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";
|
|
import { DashboardSkeleton } from "./DashboardSKeleton";
|
|
|
|
// 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: "#F97316",
|
|
},
|
|
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 <DashboardSkeleton />;
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 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/DashboardSKeleton.tsx
|
|
================
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
|
|
export function DashboardSkeleton() {
|
|
return (
|
|
<div className="flex-1 space-y-4">
|
|
<div className="h-8 w-1/4 bg-gray-200 rounded animate-pulse"></div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="h-6 w-1/3 bg-gray-200 rounded animate-pulse"></div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{[1, 2, 3].map((i) => (
|
|
<div
|
|
key={i}
|
|
className="h-12 bg-gray-200 rounded animate-pulse"
|
|
></div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="h-6 w-1/3 bg-gray-200 rounded animate-pulse"></div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="h-40 bg-gray-200 rounded animate-pulse"></div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/layout.tsx
|
|
================
|
|
import { useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Menu } from "lucide-react";
|
|
import ContextWrapper from "@/components/ContextWrapper";
|
|
import { Sidebar } from "@/components/Sidebar";
|
|
import { AuthButton } from "./AuthButton";
|
|
|
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="flex">
|
|
<Sidebar isOpen={isSidebarOpen} />
|
|
<div className="flex-1 flex flex-col">
|
|
<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>
|
|
<AuthButton />
|
|
</header>
|
|
<main className="flex-1 bg-gray-100 p-4 overflow-y-auto">
|
|
<ContextWrapper>{children}</ContextWrapper>
|
|
</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. (Apart from the lists
|
|
for the ghost blog newsletters) p.e. "New Subscribers"
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
{/* Add more fields specific to manage_subscriber action if needed */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/MinimalLayout.tsx
|
|
================
|
|
import ContextWrapper from "@/components/ContextWrapper";
|
|
|
|
export default function MinimalLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return <main className="flex min-h-screen p-4">{children}</main>;
|
|
}
|
|
|
|
================
|
|
File: components/ProtectedRoute.tsx
|
|
================
|
|
import { useEffect } from "react";
|
|
import { useRouter } from "next/router";
|
|
import { useAuthContext } from "@/contexts/AuthContext";
|
|
|
|
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
const { user, loading } = useAuthContext();
|
|
const router = useRouter();
|
|
|
|
useEffect(() => {
|
|
if (!loading && !user) {
|
|
router.push("/login");
|
|
}
|
|
}, [user, loading, router]);
|
|
|
|
if (loading) {
|
|
return <div>Loading...</div>;
|
|
}
|
|
|
|
return user ? <>{children}</> : null;
|
|
}
|
|
|
|
================
|
|
File: components/Sidebar.tsx
|
|
================
|
|
// components/Sidebar.tsx
|
|
|
|
|
|
import { usePathname } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
LayoutDashboard,
|
|
ListPlus,
|
|
ListTree,
|
|
Settings,
|
|
} from "lucide-react";
|
|
|
|
export 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-primary text-primary-foreground 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>
|
|
);
|
|
};
|
|
|
|
================
|
|
File: components/SonCreationForm.tsx
|
|
================
|
|
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 { useCustomToast } from "@/hooks/useCustomToast";
|
|
import { SonDetailsForm } from "./SonDetailsForm";
|
|
import { ActionForm } from "./ActionForm";
|
|
|
|
// Import or define your schema here
|
|
import { sonSchema } from "@/lib/schemas";
|
|
import { useSonContext } from "@/contexts/SonContext";
|
|
import { useListContext } from "@/contexts/ListContext";
|
|
import { useTemplateContext } from "@/contexts/TemplateContext";
|
|
|
|
type SonFormValues = z.infer<typeof sonSchema>;
|
|
|
|
export default function SonCreationForm() {
|
|
const { createSon } = useSonContext();
|
|
const { showToast } = useCustomToast();
|
|
const router = useRouter();
|
|
const { lists, loading: listsLoading, error: listsError } = useListContext();
|
|
const {
|
|
templates,
|
|
loading: templatesLoading,
|
|
error: templatesError,
|
|
} = useTemplateContext();
|
|
|
|
const form = useForm<SonFormValues>({
|
|
resolver: zodResolver(sonSchema),
|
|
defaultValues: {
|
|
name: "",
|
|
trigger: "member_created",
|
|
delay: "0s",
|
|
enabled: true,
|
|
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";
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
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</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
{...field}
|
|
placeholder="e.g., 30m, 2h, 1d, 1w"
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Set a delay before the Son executes its actions. Use format like "30m" for 30 minutes, "2h" for 2 hours, "1d" for 1 day, or "1w" for 1 week.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="enabled"
|
|
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">Enabled</FormLabel>
|
|
<FormDescription>Enable or disable this Son.</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/SonList.tsx
|
|
================
|
|
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, ToggleLeft, ToggleRight } from "lucide-react";
|
|
import { useSonContext } from "@/contexts/SonContext";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { SonListSkeleton } from "./SonListSkeleton";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { Son } from "@/lib/types";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
|
|
function formatDuration(duration: string): string {
|
|
console.log("duration", duration);
|
|
const match = duration.match(/^(\d+)([smhdw])$/);
|
|
if (!match) return duration;
|
|
|
|
const [, value, unit] = match;
|
|
const num = parseInt(value, 10);
|
|
|
|
switch (unit) {
|
|
case "s":
|
|
return `${num} second${num !== 1 ? "s" : ""}`;
|
|
case "m":
|
|
return `${num} minute${num !== 1 ? "s" : ""}`;
|
|
case "h":
|
|
return `${num} hour${num !== 1 ? "s" : ""}`;
|
|
case "d":
|
|
return `${num} day${num !== 1 ? "s" : ""}`;
|
|
case "w":
|
|
return `${num} week${num !== 1 ? "s" : ""}`;
|
|
default:
|
|
return duration;
|
|
}
|
|
}
|
|
|
|
export function SonList() {
|
|
const { sons, loading, error, deleteSon, updateSon } = useSonContext();
|
|
const { toast } = useToast();
|
|
|
|
if (loading) {
|
|
return <SonListSkeleton />;
|
|
}
|
|
|
|
if (error) {
|
|
return <div>Error: {error.message}</div>;
|
|
}
|
|
|
|
if (sons.length === 0) {
|
|
return <div>No sons found. Create your first son!</div>;
|
|
}
|
|
|
|
const handleToggleEnabled = async (son: Son) => {
|
|
try {
|
|
await updateSon(son.id, { ...son, enabled: !son.enabled });
|
|
toast({
|
|
title: "Son Updated",
|
|
description: `${son.name} has been ${
|
|
!son.enabled ? "enabled" : "disabled"
|
|
}.`,
|
|
variant: "default",
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to update Son status.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Your Sons</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Trigger</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Delay</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sons.map((son) => (
|
|
<TableRow key={son.id}>
|
|
<TableCell>{son.name}</TableCell>
|
|
<TableCell>{son.trigger}</TableCell>
|
|
<TableCell>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleToggleEnabled(son)}
|
|
>
|
|
{son.enabled ? (
|
|
<ToggleRight className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<ToggleLeft className="h-4 w-4 text-red-500" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{son.enabled ? "Enabled" : "Disabled"}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</TableCell>
|
|
<TableCell>{formatDuration(son.delay)}</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: components/SonListSkeleton.tsx
|
|
================
|
|
import React from "react";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
|
|
export function SonListSkeleton() {
|
|
return (
|
|
<Card className="flex-1">
|
|
<CardHeader>
|
|
<CardTitle>Your Sons</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Trigger</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{[...Array(5)].map((_, index) => (
|
|
<TableRow key={index}>
|
|
<TableCell>
|
|
<div className="h-4 bg-gray-300 rounded w-3/4 animate-pulse"></div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="h-4 bg-gray-300 rounded w-1/2 animate-pulse"></div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex space-x-2">
|
|
<div className="h-8 w-8 bg-gray-300 rounded-full animate-pulse"></div>
|
|
<div className="h-8 w-8 bg-gray-300 rounded-full animate-pulse"></div>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/TransactionalEmailActionFields.tsx
|
|
================
|
|
import React, { useState, useEffect } from "react";
|
|
import { UseFormReturn } from "react-hook-form";
|
|
import {
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormControl,
|
|
FormMessage,
|
|
FormDescription,
|
|
} from "@/components/ui/form";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Trash2, Plus } from "lucide-react";
|
|
import { ListmonkTemplate } from "@/lib/types";
|
|
import Link from "next/link";
|
|
|
|
interface TransactionalEmailActionFieldsProps {
|
|
form: UseFormReturn<any>;
|
|
index: number;
|
|
templates: ListmonkTemplate[];
|
|
}
|
|
|
|
interface Header {
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
interface EditingField {
|
|
key: string;
|
|
newKey: string;
|
|
value: string;
|
|
}
|
|
|
|
export function TransactionalEmailActionFields({
|
|
form,
|
|
index,
|
|
templates,
|
|
}: TransactionalEmailActionFieldsProps) {
|
|
const [localData, setLocalData] = useState<Record<string, string>>({});
|
|
const [editingField, setEditingField] = useState<EditingField | null>(null);
|
|
|
|
useEffect(() => {
|
|
const formData = form.getValues(`actions.${index}.parameters.data`);
|
|
if (formData && Object.keys(formData).length > 0) {
|
|
setLocalData(formData);
|
|
}
|
|
}, [form, index]);
|
|
|
|
const addDataField = () => {
|
|
const newKey = `newField${Object.keys(localData).length}`;
|
|
setLocalData((prev) => ({ ...prev, [newKey]: "" }));
|
|
setEditingField({ key: newKey, newKey: newKey, value: "" });
|
|
};
|
|
|
|
const removeDataField = (keyToRemove: string) => {
|
|
setLocalData((prev) => {
|
|
const newData = { ...prev };
|
|
delete newData[keyToRemove];
|
|
return newData;
|
|
});
|
|
setEditingField(null);
|
|
};
|
|
|
|
const handleKeyChange = (oldKey: string, newKey: string) => {
|
|
setEditingField((prev) => (prev ? { ...prev, newKey } : null));
|
|
};
|
|
|
|
const handleValueChange = (key: string, value: string) => {
|
|
setLocalData((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const handleKeyBlur = () => {
|
|
if (editingField && editingField.key !== editingField.newKey) {
|
|
setLocalData((prev) => {
|
|
const newData = { ...prev };
|
|
delete newData[editingField.key];
|
|
newData[editingField.newKey] = editingField.value;
|
|
return newData;
|
|
});
|
|
}
|
|
setEditingField(null);
|
|
};
|
|
|
|
useEffect(() => {
|
|
form.setValue(`actions.${index}.parameters.data`, localData);
|
|
}, [localData, form, index]);
|
|
return (
|
|
<>
|
|
{/* Template selection field remains unchanged */}
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.template_id`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Template</FormLabel>
|
|
<Select
|
|
onValueChange={(value: string) => field.onChange(parseInt(value))}
|
|
value={field.value?.toString()}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select a template" />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{templates.map((template) => (
|
|
<SelectItem key={template.id} value={template.id.toString()}>
|
|
{template.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
Check the available template expressions in this link -{" "}
|
|
<Link href="https://listmonk.app/docs/templating" target="_blank">
|
|
https://listmonk.app/docs/templating
|
|
</Link>
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.headers`}
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Headers</FormLabel>
|
|
<div className="space-y-2">
|
|
{((field.value as Header[]) || []).map((header, headerIndex) => (
|
|
<div key={headerIndex} className="flex items-center space-x-2">
|
|
<Input
|
|
placeholder="Key"
|
|
value={header.key}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newHeaders = [...(field.value as Header[])];
|
|
newHeaders[headerIndex].key = e.target.value;
|
|
field.onChange(newHeaders);
|
|
}}
|
|
/>
|
|
<Input
|
|
placeholder="Value"
|
|
value={header.value}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newHeaders = [...(field.value as Header[])];
|
|
newHeaders[headerIndex].value = e.target.value;
|
|
field.onChange(newHeaders);
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const newHeaders = (field.value as Header[]).filter(
|
|
(_, i) => i !== headerIndex
|
|
);
|
|
field.onChange(newHeaders);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const newHeaders = [
|
|
...((field.value as Header[]) || []),
|
|
{ key: "", value: "" },
|
|
];
|
|
field.onChange(newHeaders);
|
|
}}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" /> Add Header
|
|
</Button>
|
|
</div>
|
|
<FormMessage />
|
|
<FormDescription>
|
|
Headers will be added on the email, you can use this to track
|
|
campaigns.
|
|
</FormDescription>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name={`actions.${index}.parameters.data`}
|
|
render={() => (
|
|
<FormItem>
|
|
<FormLabel>Additional Data</FormLabel>
|
|
<div className="space-y-2">
|
|
{Object.entries(localData).map(([key, value]) => (
|
|
<div key={key} className="flex items-center space-x-2">
|
|
<Input
|
|
placeholder="Key"
|
|
value={
|
|
editingField?.key === key ? editingField.newKey : key
|
|
}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
handleKeyChange(key, e.target.value)
|
|
}
|
|
onFocus={() => setEditingField({ key, newKey: key, value })}
|
|
onBlur={handleKeyBlur}
|
|
/>
|
|
<Input
|
|
placeholder="Value"
|
|
value={value}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
handleValueChange(key, e.target.value)
|
|
}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeDataField(key)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addDataField}
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" /> Add Data Field
|
|
</Button>
|
|
</div>
|
|
<FormMessage />
|
|
<FormDescription>
|
|
<p>Enter any additional data for the transactional email.</p>
|
|
<p>
|
|
Available in the template as{" "}
|
|
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
|
|
{{ .Tx.Data.* }}
|
|
</code>
|
|
</p>
|
|
</FormDescription>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: components/WebhookInfo.tsx
|
|
================
|
|
// Create a new file: ui/components/WebhookInfo.tsx
|
|
import React, { useState } from "react";
|
|
import { useSonContext } from "@/contexts/SonContext";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Eye, EyeOff } from "lucide-react";
|
|
|
|
export function WebhookInfo() {
|
|
const { webhook, webhookLoading, webhookError } = useSonContext();
|
|
const [showSecret, setShowSecret] = useState(false);
|
|
|
|
if (webhookLoading) {
|
|
return <div>Loading webhook information...</div>;
|
|
}
|
|
|
|
if (webhookError) {
|
|
return <div>Error loading webhook information: {webhookError.message}</div>;
|
|
}
|
|
|
|
if (!webhook) {
|
|
return <div>No webhook information available.</div>;
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Webhook Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Endpoint:</label>
|
|
<Input value={webhook.endpoint} readOnly />
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Secret:</label>
|
|
<div className="flex items-center space-x-2">
|
|
<Input
|
|
type={showSecret ? "text" : "password"}
|
|
value={webhook.secret}
|
|
readOnly
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setShowSecret(!showSecret)}
|
|
>
|
|
{showSecret ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: contexts/AuthContext.tsx
|
|
================
|
|
import { createContext, useContext, useState, useEffect } from "react";
|
|
import { apiClient } from "@/lib/api-client";
|
|
|
|
interface User {
|
|
id: string;
|
|
email: string;
|
|
}
|
|
|
|
interface AuthContextType {
|
|
user: User | null;
|
|
login: (token: string, user: User) => Promise<void>;
|
|
logout: () => Promise<void>;
|
|
loading: boolean;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextType | null>(null);
|
|
|
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
children,
|
|
}) => {
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem("authToken");
|
|
const storedUser = localStorage.getItem("user");
|
|
if (token && storedUser) {
|
|
setUser(JSON.parse(storedUser));
|
|
apiClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
|
}
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
const login = async (token: string, user: User) => {
|
|
localStorage.setItem("authToken", token);
|
|
localStorage.setItem("user", JSON.stringify(user));
|
|
apiClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
|
setUser(user);
|
|
};
|
|
|
|
const logout = async () => {
|
|
try {
|
|
await apiClient.post("/auth/logout");
|
|
} catch (error) {
|
|
console.error("Logout failed:", error);
|
|
} finally {
|
|
localStorage.removeItem("authToken");
|
|
localStorage.removeItem("user");
|
|
delete apiClient.defaults.headers.common["Authorization"];
|
|
setUser(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ user, login, logout, loading }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useAuthContext = () => {
|
|
const context = useContext(AuthContext);
|
|
if (!context) {
|
|
throw new Error("useAuth must be used within an AuthProvider");
|
|
}
|
|
return context;
|
|
};
|
|
|
|
================
|
|
File: contexts/ListContext.tsx
|
|
================
|
|
import React, { createContext, useContext } from "react";
|
|
import { ListmonkList } from "@/lib/types";
|
|
import { useLists } from "@/hooks/useLists";
|
|
|
|
interface ListContextProps {
|
|
lists: ListmonkList[];
|
|
loading: boolean;
|
|
error: Error | null;
|
|
fetchLists: () => void;
|
|
}
|
|
|
|
const ListContext = createContext<ListContextProps | undefined>(undefined);
|
|
|
|
export const ListProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
children,
|
|
}) => {
|
|
const { lists, loading, error, fetchLists } = useLists();
|
|
|
|
return (
|
|
<ListContext.Provider
|
|
value={{
|
|
lists,
|
|
loading,
|
|
error,
|
|
fetchLists,
|
|
}}
|
|
>
|
|
{children}
|
|
</ListContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useListContext = () => {
|
|
const context = useContext(ListContext);
|
|
if (!context) {
|
|
throw new Error("useListContext must be used within a ListProvider");
|
|
}
|
|
return context;
|
|
};
|
|
|
|
================
|
|
File: contexts/SonContext.tsx
|
|
================
|
|
// ui/contexts/SonContext.tsx
|
|
import React, { createContext, useContext } from "react";
|
|
import { Son } from "@/lib/types";
|
|
import { useSons } from "@/hooks/useSons";
|
|
import { Dispatch, SetStateAction } from "react";
|
|
import { useWebhook } from "@/hooks/useWebhook";
|
|
import { Webhook } from "@/lib/types";
|
|
|
|
interface SonContextProps {
|
|
sons: Son[];
|
|
setSons: Dispatch<SetStateAction<Son[]>>;
|
|
loading: boolean;
|
|
error: Error | null;
|
|
fetchSons: () => void;
|
|
createSon: (
|
|
sonData: Omit<Son, "id" | "created_at" | "updated_at">
|
|
) => Promise<Son>;
|
|
updateSon: (id: string, sonData: Partial<Son>) => Promise<Son>;
|
|
deleteSon: (id: string) => Promise<void>;
|
|
webhook: Webhook | null;
|
|
webhookLoading: boolean;
|
|
webhookError: Error | null;
|
|
fetchWebhook: () => Promise<void>;
|
|
}
|
|
|
|
const SonContext = createContext<SonContextProps | undefined>(undefined);
|
|
|
|
export const SonProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
children,
|
|
}) => {
|
|
const {
|
|
sons,
|
|
setSons,
|
|
loading,
|
|
error,
|
|
fetchSons,
|
|
createSon,
|
|
updateSon,
|
|
deleteSon,
|
|
} = useSons();
|
|
const {
|
|
webhook,
|
|
loading: webhookLoading,
|
|
error: webhookError,
|
|
fetchWebhook,
|
|
} = useWebhook();
|
|
|
|
return (
|
|
<SonContext.Provider
|
|
value={{
|
|
sons,
|
|
setSons,
|
|
loading,
|
|
error,
|
|
fetchSons,
|
|
createSon,
|
|
updateSon,
|
|
deleteSon,
|
|
webhook,
|
|
webhookLoading,
|
|
webhookError,
|
|
fetchWebhook,
|
|
}}
|
|
>
|
|
{children}
|
|
</SonContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useSonContext = () => {
|
|
const context = useContext(SonContext);
|
|
if (!context) {
|
|
throw new Error("useSonContext must be used within a SonProvider");
|
|
}
|
|
return context;
|
|
};
|
|
|
|
================
|
|
File: contexts/TemplateContext.tsx
|
|
================
|
|
import React, { createContext, useContext } from "react";
|
|
import { ListmonkTemplate } from "@/lib/types";
|
|
import { useTemplates } from "@/hooks/useTemplates";
|
|
|
|
interface TemplateContextProps {
|
|
templates: ListmonkTemplate[];
|
|
loading: boolean;
|
|
error: Error | null;
|
|
fetchTemplates: () => void;
|
|
}
|
|
|
|
const TemplateContext = createContext<TemplateContextProps | undefined>(
|
|
undefined
|
|
);
|
|
|
|
export const TemplateProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
children,
|
|
}) => {
|
|
const { templates, loading, error, fetchTemplates } = useTemplates();
|
|
|
|
return (
|
|
<TemplateContext.Provider
|
|
value={{
|
|
templates,
|
|
loading,
|
|
error,
|
|
fetchTemplates,
|
|
}}
|
|
>
|
|
{children}
|
|
</TemplateContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useTemplateContext = () => {
|
|
const context = useContext(TemplateContext);
|
|
if (!context) {
|
|
throw new Error(
|
|
"useTemplateContext must be used within a TemplateProvider"
|
|
);
|
|
}
|
|
return context;
|
|
};
|
|
|
|
================
|
|
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/useInitialData.ts
|
|
================
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { Son, Webhook, ListmonkList, ListmonkTemplate } from '@/lib/types';
|
|
|
|
interface InitialData {
|
|
sons: Son[];
|
|
webhook: Webhook;
|
|
lists: ListmonkList[];
|
|
templates: ListmonkTemplate[];
|
|
}
|
|
|
|
export function useInitialData() {
|
|
const [data, setData] = useState<InitialData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchInitialData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get<InitialData>('/initial-data');
|
|
setData(response.data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchInitialData();
|
|
}, [fetchInitialData]);
|
|
|
|
return { data, loading, error, fetchInitialData };
|
|
}
|
|
|
|
================
|
|
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
|
|
================
|
|
import { useState, useEffect } from "react";
|
|
import { useSonContext } from "@/contexts/SonContext";
|
|
import { Son } from "@/lib/types";
|
|
import { apiClient } from "@/lib/api-client";
|
|
|
|
export function useSon(id: string) {
|
|
const { sons, setSons } = useSonContext(); // Assuming you expose setSons in the context
|
|
const [son, setSon] = useState<Son | null>(null);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Check if the Son is already in the context
|
|
const existingSon = sons.find((s) => s.id === id);
|
|
if (existingSon) {
|
|
setSon(existingSon);
|
|
setLoading(false);
|
|
} else {
|
|
// If not, fetch from the server
|
|
const fetchSon = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get<Son>(`/sons/${id}`);
|
|
setSon(response.data);
|
|
setError(null);
|
|
|
|
// Optimistically update the context with the new Son
|
|
setSons((prevSons) => [...prevSons, response.data]);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error("An error occurred"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchSon();
|
|
}
|
|
}, [id, sons, setSons]);
|
|
|
|
return { son, loading, error };
|
|
}
|
|
|
|
================
|
|
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(); // Initial data fetch
|
|
}, [fetchSons]);
|
|
|
|
|
|
return { sons, setSons, 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: hooks/useWebhook.ts
|
|
================
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { Webhook } from '@/lib/types';
|
|
|
|
|
|
export function useWebhook() {
|
|
const [webhook, setWebhook] = useState<Webhook | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<Error | null>(null);
|
|
|
|
const fetchWebhook = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await apiClient.get<{ data: Webhook }>('/webhook-info');
|
|
setWebhook(response.data.data);
|
|
setError(null);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error('An error occurred'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchWebhook();
|
|
}, [fetchWebhook]);
|
|
|
|
return { webhook, loading, error, fetchWebhook };
|
|
}
|
|
|
|
================
|
|
File: lib/api-client.ts
|
|
================
|
|
import axios from 'axios';
|
|
|
|
const API_BASE_URL = '/api';
|
|
|
|
export const apiClient = axios.create({
|
|
baseURL: API_BASE_URL,
|
|
withCredentials: true,
|
|
});
|
|
|
|
apiClient.interceptors.request.use(
|
|
(config) => {
|
|
const token = localStorage.getItem('authToken');
|
|
if (token) {
|
|
config.headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
return config;
|
|
},
|
|
(error) => Promise.reject(error)
|
|
);
|
|
|
|
apiClient.interceptors.response.use(
|
|
(response) => response,
|
|
async (error) => {
|
|
if (error.response && error.response.status === 401) {
|
|
localStorage.removeItem('authToken');
|
|
localStorage.removeItem('user');
|
|
delete apiClient.defaults.headers.common['Authorization'];
|
|
window.dispatchEvent(new CustomEvent('unauthorized'));
|
|
window.location.href = '/login';
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
export default apiClient;
|
|
|
|
================
|
|
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.string().refine((val) => {
|
|
const durationRegex = /^(\d+)\s*(s|m|h|d|w)$/;
|
|
return durationRegex.test(val);
|
|
}, {
|
|
message: "Invalid duration format. Use format like '30m', '2h', '1d', or '1w'.",
|
|
}).default('0s'),
|
|
actions: z.array(actionSchema).min(1, 'At least one action is required'),
|
|
enabled: z.boolean().default(true),
|
|
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.string().min(2).default('0s').refine((val) => {
|
|
const durationRegex = /^(\d+)\s*(s|m|h|d|w)$/;
|
|
return durationRegex.test(val);
|
|
}, {
|
|
message: "Invalid duration format. Use format like '30m', '2h', '1d', or '1w'.",
|
|
}),
|
|
actions: z.array(z.object({
|
|
type: z.enum(['send_transactional_email', 'manage_subscriber', 'create_campaign']),
|
|
parameters: z.record(z.any()),
|
|
})),
|
|
enabled: z.boolean().default(true),
|
|
});
|
|
|
|
|
|
// 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[];
|
|
}
|
|
|
|
export interface Webhook {
|
|
id: string;
|
|
endpoint: string;
|
|
secret: string;
|
|
}
|
|
|
|
================
|
|
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: pages/auth/verify.tsx
|
|
================
|
|
import { useEffect } from "react";
|
|
import { useRouter } from "next/router";
|
|
import { useAuthContext } from "@/contexts/AuthContext";
|
|
import { useCustomToast } from "@/hooks/useCustomToast";
|
|
import { apiClient } from "@/lib/api-client";
|
|
|
|
const VerifyPage = () => {
|
|
const router = useRouter();
|
|
const { login } = useAuthContext();
|
|
const { showToast } = useCustomToast();
|
|
|
|
useEffect(() => {
|
|
const verifyToken = async () => {
|
|
const { token } = router.query;
|
|
if (token) {
|
|
try {
|
|
const response = await apiClient.get(`/auth/verify?token=${token}`);
|
|
await login(response.data.token, response.data.user);
|
|
showToast("Success", "Logged in successfully");
|
|
router.push("/");
|
|
} catch (error) {
|
|
console.error("Verification failed:", error);
|
|
showToast("Error", "Verification failed", "destructive");
|
|
router.push("/login");
|
|
}
|
|
}
|
|
};
|
|
|
|
verifyToken();
|
|
}, [router.query]);
|
|
|
|
return (
|
|
<div className="flex items-center justify-center h-screen">
|
|
<p>Verifying your magic link...</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
VerifyPage.layout = "minimal";
|
|
|
|
export default VerifyPage;
|
|
|
|
================
|
|
File: pages/sons/[id].tsx
|
|
================
|
|
import { NextPageWithExtras } from "next";
|
|
|
|
import React, { useEffect } from "react";
|
|
import { useRouter } from "next/router";
|
|
import { useForm, useFieldArray } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@/components/ui/alert-dialog";
|
|
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 { useCustomToast } from "@/hooks/useCustomToast";
|
|
import { SonDetailsForm } from "@/components/SonDetailsForm";
|
|
import { ActionForm } from "@/components/ActionForm";
|
|
import { editableSonSchema } from "@/lib/schemas";
|
|
import { EditableSon } from "@/lib/types";
|
|
import { useSonContext } from "@/contexts/SonContext";
|
|
import { useListContext } from "@/contexts/ListContext";
|
|
import { useTemplateContext } from "@/contexts/TemplateContext";
|
|
|
|
function isValidTrigger(trigger: string): trigger is EditableSon["trigger"] {
|
|
return [
|
|
"member_created",
|
|
"member_deleted",
|
|
"member_updated",
|
|
"post_published",
|
|
"post_scheduled",
|
|
].includes(trigger);
|
|
}
|
|
|
|
const SonDetailPage: NextPageWithExtras = () => {
|
|
const router = useRouter();
|
|
const { id } = router.query;
|
|
const { son, loading: sonLoading, error: sonError } = useSon(id as string);
|
|
const { updateSon, deleteSon } = useSonContext();
|
|
const { showToast } = useCustomToast();
|
|
const { lists, loading: listsLoading, error: listsError } = useListContext();
|
|
const {
|
|
templates,
|
|
loading: templatesLoading,
|
|
error: templatesError,
|
|
} = useTemplateContext();
|
|
|
|
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;
|
|
|
|
if (isValidTrigger(trigger)) {
|
|
form.reset({
|
|
name,
|
|
trigger,
|
|
delay,
|
|
actions: actions.map((action) => ({
|
|
type: action.type,
|
|
parameters: action.parameters,
|
|
})),
|
|
});
|
|
} else {
|
|
console.error(`Invalid trigger value: ${trigger}`);
|
|
form.reset({
|
|
name,
|
|
trigger: "member_created",
|
|
delay,
|
|
actions: actions.map((action) => ({
|
|
type: action.type,
|
|
parameters: action.parameters,
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
}, [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);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
await deleteSon(son.id);
|
|
showToast("Success", "Son deleted successfully");
|
|
router.push("/sons");
|
|
} catch (error) {
|
|
showToast("Error", "Failed to delete Son", "destructive");
|
|
console.error("Failed to delete Son:", error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex-1 max-w-3xl mx-auto 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>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button variant="destructive">Delete Son</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={handleDelete}>Delete</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
SonDetailPage.auth = true;
|
|
|
|
export default SonDetailPage;
|
|
|
|
================
|
|
File: pages/sons/index.tsx
|
|
================
|
|
import type { NextPageWithExtras } from "next";
|
|
import Link from "next/link";
|
|
import { Button } from "@/components/ui/button";
|
|
import { SonList } from "@/components/SonList";
|
|
|
|
const SonListPage: NextPageWithExtras = () => {
|
|
return (
|
|
<div className="flex-1 space-y-4 max-w-3xl mx-auto">
|
|
<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>
|
|
);
|
|
};
|
|
|
|
SonListPage.auth = true;
|
|
|
|
export default SonListPage;
|
|
|
|
================
|
|
File: pages/sons/new.tsx
|
|
================
|
|
import { NextPageWithExtras } from "next";
|
|
import SonCreationForm from "@/components/SonCreationForm";
|
|
|
|
const NewSonPage: NextPageWithExtras = () => {
|
|
return (
|
|
<div className="flex-1 max-w-3xl mx-auto">
|
|
<h1 className="text-2xl font-bold mb-4">Create New Son</h1>
|
|
<SonCreationForm />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
NewSonPage.auth = true;
|
|
|
|
export default NewSonPage;
|
|
|
|
================
|
|
File: pages/_app.tsx
|
|
================
|
|
// ui/pages/_app.tsx
|
|
"use client";
|
|
import { AppProps } from "next/app";
|
|
import type { NextPageWithExtras } from "next";
|
|
import localFont from "next/font/local";
|
|
import { cn } from "@/lib/utils";
|
|
import DefaultLayout from "@/components/layout";
|
|
import MinimalLayout from "@/components/MinimalLayout";
|
|
import { Toaster } from "@/components/ui/toaster";
|
|
import "@/styles/globals.css";
|
|
import { ProtectedRoute } from "@/components/ProtectedRoute";
|
|
import { AuthProvider } from "@/contexts/AuthContext";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { useRouter } from "next/router";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { useEffect } from "react";
|
|
|
|
const fontSans = localFont({
|
|
src: "../public/fonts/inter-var-latin.woff2",
|
|
variable: "--font-sans",
|
|
});
|
|
|
|
type AppPropsWithAuth = AppProps & {
|
|
Component: NextPageWithExtras;
|
|
};
|
|
|
|
function MyApp({ Component, pageProps }: AppPropsWithAuth) {
|
|
const router = useRouter();
|
|
const Layout = Component.layout === "minimal" ? MinimalLayout : DefaultLayout;
|
|
const { toast } = useToast();
|
|
|
|
useEffect(() => {
|
|
const handleUnauthorized = (event: Event) => {
|
|
if (event instanceof CustomEvent && event.detail === "unauthorized") {
|
|
toast({
|
|
title: "Session Expired",
|
|
description:
|
|
"You've been logged out due to inactivity. Please log in again.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
window.addEventListener("unauthorized", handleUnauthorized);
|
|
|
|
return () => {
|
|
window.removeEventListener("unauthorized", handleUnauthorized);
|
|
};
|
|
}, [toast]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"min-h-screen max-h-screen bg-background font-sans antialiased",
|
|
fontSans.variable
|
|
)}
|
|
>
|
|
<AuthProvider>
|
|
<AnimatePresence mode="wait">
|
|
<Layout>
|
|
<motion.div
|
|
key={router.route}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="flex w-full"
|
|
>
|
|
{Component.auth ? (
|
|
<ProtectedRoute>
|
|
<Component {...pageProps} />
|
|
</ProtectedRoute>
|
|
) : (
|
|
<Component {...pageProps} />
|
|
)}
|
|
</motion.div>
|
|
</Layout>
|
|
</AnimatePresence>
|
|
</AuthProvider>
|
|
<Toaster />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default MyApp;
|
|
|
|
================
|
|
File: pages/_document.tsx
|
|
================
|
|
import { Html, Head, Main, NextScript } from "next/document";
|
|
|
|
export default function Document() {
|
|
return (
|
|
<Html lang="en">
|
|
<Head />
|
|
<body>
|
|
<Main />
|
|
<NextScript />
|
|
</body>
|
|
</Html>
|
|
);
|
|
}
|
|
|
|
================
|
|
File: pages/index.tsx
|
|
================
|
|
import { NextPageWithExtras } from "next";
|
|
import Dashboard from "@/components/Dashboard";
|
|
|
|
const HomePage: NextPageWithExtras = () => {
|
|
return <Dashboard />;
|
|
};
|
|
|
|
HomePage.auth = true;
|
|
|
|
export default HomePage;
|
|
|
|
================
|
|
File: pages/login.tsx
|
|
================
|
|
import { useState } from "react";
|
|
import { useRouter } from "next/router";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardContent,
|
|
CardFooter,
|
|
} from "@/components/ui/card";
|
|
import { useCustomToast } from "@/hooks/useCustomToast";
|
|
import { apiClient } from "@/lib/api-client";
|
|
|
|
const LoginPage = () => {
|
|
const [email, setEmail] = useState("");
|
|
const router = useRouter();
|
|
const { showToast } = useCustomToast();
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
await apiClient.post("/auth/magic-link", { email });
|
|
showToast("Success", "Magic link sent to your email");
|
|
} catch (error) {
|
|
console.error("Failed to send magic link:", error);
|
|
showToast("Error", "Failed to send magic link", "destructive");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col w-full items-center justify-center bg-gray-100">
|
|
<h1>
|
|
<span className="font-bold text-2xl">Ghost-Listmonk Connector</span>
|
|
</h1>
|
|
<Card className="w-full max-w-md mt-12">
|
|
<CardHeader>
|
|
<CardTitle>Login</CardTitle>
|
|
</CardHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<CardContent className="space-y-4">
|
|
<Input
|
|
type="email"
|
|
placeholder="Email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
/>
|
|
</CardContent>
|
|
<CardFooter>
|
|
<Button type="submit" className="w-full">
|
|
Send Magic Link
|
|
</Button>
|
|
</CardFooter>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
LoginPage.layout = "minimal";
|
|
|
|
export default LoginPage;
|
|
|
|
================
|
|
File: pages/settings.tsx
|
|
================
|
|
import { NextPageWithExtras } from "next";
|
|
import { WebhookInfo } from "@/components/WebhookInfo";
|
|
|
|
const SettingsPage: NextPageWithExtras = () => {
|
|
return (
|
|
<div className="flex-1 space-y-4 max-w-3xl mx-auto">
|
|
<h1 className="text-2xl font-bold">Settings</h1>
|
|
<WebhookInfo />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
SettingsPage.auth = true;
|
|
|
|
export default SettingsPage;
|
|
|
|
================
|
|
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 {
|
|
@layer base {
|
|
:root {
|
|
--background: 0 0% 100%;
|
|
--foreground: 20 14.3% 4.1%;
|
|
--card: 0 0% 100%;
|
|
--card-foreground: 20 14.3% 4.1%;
|
|
--popover: 0 0% 100%;
|
|
--popover-foreground: 20 14.3% 4.1%;
|
|
--primary: 24.6 95% 53.1%;
|
|
--primary-foreground: 60 9.1% 97.8%;
|
|
--secondary: 60 4.8% 95.9%;
|
|
--secondary-foreground: 24 9.8% 10%;
|
|
--muted: 60 4.8% 95.9%;
|
|
--muted-foreground: 25 5.3% 44.7%;
|
|
--accent: 60 4.8% 95.9%;
|
|
--accent-foreground: 24 9.8% 10%;
|
|
--destructive: 0 84.2% 60.2%;
|
|
--destructive-foreground: 60 9.1% 97.8%;
|
|
--border: 20 5.9% 90%;
|
|
--input: 20 5.9% 90%;
|
|
--ring: 24.6 95% 53.1%;
|
|
--radius: 1rem;
|
|
--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: 20 14.3% 4.1%;
|
|
--foreground: 60 9.1% 97.8%;
|
|
--card: 20 14.3% 4.1%;
|
|
--card-foreground: 60 9.1% 97.8%;
|
|
--popover: 20 14.3% 4.1%;
|
|
--popover-foreground: 60 9.1% 97.8%;
|
|
--primary: 20.5 90.2% 48.2%;
|
|
--primary-foreground: 60 9.1% 97.8%;
|
|
--secondary: 12 6.5% 15.1%;
|
|
--secondary-foreground: 60 9.1% 97.8%;
|
|
--muted: 12 6.5% 15.1%;
|
|
--muted-foreground: 24 5.4% 63.9%;
|
|
--accent: 12 6.5% 15.1%;
|
|
--accent-foreground: 60 9.1% 97.8%;
|
|
--destructive: 0 72.2% 50.6%;
|
|
--destructive-foreground: 60 9.1% 97.8%;
|
|
--border: 12 6.5% 15.1%;
|
|
--input: 12 6.5% 15.1%;
|
|
--ring: 20.5 90.2% 48.2%;
|
|
--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: types/next-auth.d.ts
|
|
================
|
|
import type { NextComponentType, NextPageContext } from 'next'
|
|
|
|
declare module 'next' {
|
|
export type NextPageWithExtras<P = {}, IP = P> = NextComponentType<NextPageContext, IP, P> & {
|
|
auth?: boolean,
|
|
layout?: 'default' | 'admin' | 'minimal' | (string & {})
|
|
permissions?: string[]
|
|
dataFetching?: 'ssg' | 'ssr' | 'csr'
|
|
}
|
|
}
|
|
|
|
================
|
|
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": "new-york",
|
|
"rsc": false,
|
|
"tsx": true,
|
|
"tailwind": {
|
|
"config": "tailwind.config.ts",
|
|
"css": "styles/globals.css",
|
|
"baseColor": "zinc",
|
|
"cssVariables": true,
|
|
"prefix": ""
|
|
},
|
|
"aliases": {
|
|
"components": "@/components",
|
|
"utils": "@/lib/utils"
|
|
}
|
|
}
|
|
|
|
================
|
|
File: next.config.mjs
|
|
================
|
|
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {
|
|
reactStrictMode: true,
|
|
output: "export",
|
|
// assetPrefix: ".",
|
|
images: {
|
|
unoptimized: true,
|
|
},
|
|
};
|
|
|
|
export default nextConfig;
|
|
|
|
================
|
|
File: package.json
|
|
================
|
|
{
|
|
"name": "ui2",
|
|
"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-icons": "^1.3.0",
|
|
"@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",
|
|
"@radix-ui/react-tooltip": "^1.1.2",
|
|
"axios": "^1.7.3",
|
|
"class-variance-authority": "^0.7.0",
|
|
"clsx": "^2.1.1",
|
|
"framer-motion": "^11.3.24",
|
|
"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.5.1",
|
|
"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 `pages/index.tsx`. The page auto-updates as you edit the file.
|
|
|
|
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
|
|
|
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
|
|
|
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"
|
|
import { fontFamily } from "tailwindcss/defaultTheme"
|
|
|
|
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: {
|
|
fontFamily: {
|
|
sans: ["var(--font-sans)", ...fontFamily.sans],
|
|
},
|
|
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,
|
|
"typeRoots": ["./node_modules/@types", "./types"],
|
|
"paths": {
|
|
"@/*": ["./*"]
|
|
}
|
|
},
|
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
|
"exclude": ["node_modules"]
|
|
}
|