+
+
+ {isSignup ? "Create new account" : "Sign into Unsend"}
+
+
+ {isSignup ? "Already have an account?" : "New to Unsend?"}
+
+ {isSignup ? "Sign in" : "Create new account"}
+
+
+
+
+
{providers &&
Object.values(providers).map((provider) => {
if (provider.type === "email") return null;
@@ -118,10 +144,17 @@ export default function LoginPage({
key={provider.id}
className="w-[350px]"
size="lg"
- onClick={() => signIn(provider.id)}
+ onClick={() => handleSubmit(provider.id)}
>
- {providerSvgs[provider.id as keyof typeof providerSvgs]}
- Continue with {provider.name}
+ {submittedProvider === provider.id ? (
+
+ ) : (
+ providerSvgs[provider.id as keyof typeof providerSvgs]
+ )}
+
+ {isSignup ? "Sign up with" : "Continue with"}{" "}
+ {provider.name}
+
);
})}
@@ -131,7 +164,7 @@ export default function LoginPage({
or
-
+
{emailStatus === "success" ? (
<>
@@ -141,7 +174,7 @@ export default function LoginPage({
@@ -197,7 +230,7 @@ export default function LoginPage({
diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx
new file mode 100644
index 0000000..ba9d53b
--- /dev/null
+++ b/apps/web/src/app/signup/page.tsx
@@ -0,0 +1,16 @@
+import { redirect } from "next/navigation";
+import { getServerAuthSession } from "~/server/auth";
+import LoginPage from "../login/login-page";
+import { getProviders } from "next-auth/react";
+
+export default async function Login() {
+ const session = await getServerAuthSession();
+
+ if (session) {
+ redirect("/dashboard");
+ }
+
+ const providers = await getProviders();
+
+ return
;
+}
diff --git a/apps/web/src/components/theme/ThemeSwitcher.tsx b/apps/web/src/components/theme/ThemeSwitcher.tsx
new file mode 100644
index 0000000..6099e9e
--- /dev/null
+++ b/apps/web/src/components/theme/ThemeSwitcher.tsx
@@ -0,0 +1,45 @@
+import { cn, useTheme } from "@unsend/ui";
+import { Button } from "@unsend/ui/src/button";
+import { Monitor, Sun, Moon, SunMoonIcon } from "lucide-react";
+
+export const ThemeSwitcher = () => {
+ const { theme, setTheme, systemTheme } = useTheme();
+
+ return (
+
+
+
+ Theme
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts
index b8baca8..00d5969 100644
--- a/apps/web/src/server/api/routers/campaign.ts
+++ b/apps/web/src/server/api/routers/campaign.ts
@@ -1,5 +1,6 @@
-import { Prisma } from "@prisma/client";
+import { CampaignStatus, Prisma } from "@prisma/client";
import { TRPCError } from "@trpc/server";
+import { EmailRenderer } from "@unsend/email-editor/src/renderer";
import { z } from "zod";
import { env } from "~/env";
import {
@@ -20,11 +21,14 @@ import {
isStorageConfigured,
} from "~/server/service/storage-service";
+const statuses = Object.values(CampaignStatus) as [CampaignStatus];
+
export const campaignRouter = createTRPCRouter({
getCampaigns: teamProcedure
.input(
z.object({
page: z.number().optional(),
+ status: z.enum(statuses).optional().nullable(),
})
)
.query(async ({ ctx: { db, team }, input }) => {
@@ -36,6 +40,10 @@ export const campaignRouter = createTRPCRouter({
teamId: team.id,
};
+ if (input.status) {
+ whereConditions.status = input.status;
+ }
+
const countP = db.campaign.count({ where: whereConditions });
const campaignsP = db.campaign.findMany({
@@ -48,6 +56,7 @@ export const campaignRouter = createTRPCRouter({
createdAt: true,
updatedAt: true,
status: true,
+ html: true,
},
orderBy: {
createdAt: "desc",
@@ -92,6 +101,7 @@ export const campaignRouter = createTRPCRouter({
previewText: z.string().optional(),
content: z.string().optional(),
contactBookId: z.string().optional(),
+ replyTo: z.string().array().optional(),
})
)
.mutation(async ({ ctx: { db, team, campaign: campaignOld }, input }) => {
@@ -113,10 +123,21 @@ export const campaignRouter = createTRPCRouter({
const domain = await validateDomainFromEmail(data.from, team.id);
domainId = domain.id;
}
+
+ let html: string | null = null;
+
+ if (data.content) {
+ const jsonContent = data.content ? JSON.parse(data.content) : null;
+
+ const renderer = new EmailRenderer(jsonContent);
+ html = await renderer.render();
+ }
+
const campaign = await db.campaign.update({
where: { id: campaignId },
data: {
...data,
+ html,
domainId,
},
});
diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts
index 45dc14e..48ff000 100644
--- a/apps/web/src/server/api/routers/contacts.ts
+++ b/apps/web/src/server/api/routers/contacts.ts
@@ -1,4 +1,4 @@
-import { Prisma } from "@prisma/client";
+import { CampaignStatus, Prisma } from "@prisma/client";
import { z } from "zod";
import {
@@ -43,19 +43,31 @@ export const contactsRouter = createTRPCRouter({
getContactBookDetails: contactBookProcedure.query(
async ({ ctx: { contactBook, db } }) => {
- const [totalContacts, unsubscribedContacts] = await Promise.all([
- db.contact.count({
- where: { contactBookId: contactBook.id },
- }),
- db.contact.count({
- where: { contactBookId: contactBook.id, subscribed: false },
- }),
- ]);
+ const [totalContacts, unsubscribedContacts, campaigns] =
+ await Promise.all([
+ db.contact.count({
+ where: { contactBookId: contactBook.id },
+ }),
+ db.contact.count({
+ where: { contactBookId: contactBook.id, subscribed: false },
+ }),
+ db.campaign.findMany({
+ where: {
+ contactBookId: contactBook.id,
+ status: CampaignStatus.SENT,
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ take: 2,
+ }),
+ ]);
return {
...contactBook,
totalContacts,
unsubscribedContacts,
+ campaigns,
};
}
),
@@ -66,6 +78,7 @@ export const contactsRouter = createTRPCRouter({
contactBookId: z.string(),
name: z.string().optional(),
properties: z.record(z.string()).optional(),
+ emoji: z.string().optional(),
})
)
.mutation(async ({ ctx: { db }, input }) => {
diff --git a/apps/web/src/server/api/routers/email.ts b/apps/web/src/server/api/routers/email.ts
index 66a028b..f195a76 100644
--- a/apps/web/src/server/api/routers/email.ts
+++ b/apps/web/src/server/api/routers/email.ts
@@ -177,7 +177,7 @@ export const emailRouter = createTRPCRouter({
select: {
emailEvents: {
orderBy: {
- status: "asc",
+ status: "desc",
},
},
id: true,
diff --git a/apps/web/src/server/service/campaign-service.ts b/apps/web/src/server/service/campaign-service.ts
index e1ed86d..d875125 100644
--- a/apps/web/src/server/service/campaign-service.ts
+++ b/apps/web/src/server/service/campaign-service.ts
@@ -188,6 +188,7 @@ type CampainEmail = {
from: string;
subject: string;
html: string;
+ previewText?: string;
replyTo?: string[];
cc?: string[];
bcc?: string[];
@@ -199,8 +200,17 @@ export async function sendCampaignEmail(
campaign: Campaign,
emailData: CampainEmail
) {
- const { campaignId, from, subject, replyTo, cc, bcc, teamId, contacts } =
- emailData;
+ const {
+ campaignId,
+ from,
+ subject,
+ replyTo,
+ cc,
+ bcc,
+ teamId,
+ contacts,
+ previewText,
+ } = emailData;
const jsonContent = JSON.parse(campaign.content || "{}");
const renderer = new EmailRenderer(jsonContent);
@@ -242,6 +252,7 @@ export async function sendCampaignEmail(
from,
subject,
html: contact.html,
+ text: previewText,
teamId,
campaignId,
contactId: contact.id,
diff --git a/packages/email-editor/src/extensions/dragHandle.ts b/packages/email-editor/src/extensions/dragHandle.ts
new file mode 100644
index 0000000..d5308c0
--- /dev/null
+++ b/packages/email-editor/src/extensions/dragHandle.ts
@@ -0,0 +1,390 @@
+import { Extension } from "@tiptap/core";
+import {
+ NodeSelection,
+ Plugin,
+ PluginKey,
+ TextSelection,
+} from "@tiptap/pm/state";
+import { Fragment, Slice, Node } from "@tiptap/pm/model";
+
+// @ts-ignore
+import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
+
+export interface GlobalDragHandleOptions {
+ /**
+ * The width of the drag handle
+ */
+ dragHandleWidth: number;
+
+ /**
+ * The treshold for scrolling
+ */
+ scrollTreshold: number;
+
+ /*
+ * The css selector to query for the drag handle. (eg: '.custom-handle').
+ * If handle element is found, that element will be used as drag handle. If not, a default handle will be created
+ */
+ dragHandleSelector?: string;
+
+ /**
+ * Tags to be excluded for drag handle
+ */
+ excludedTags: string[];
+}
+function absoluteRect(node: Element) {
+ const data = node.getBoundingClientRect();
+ const modal = node.closest('[role="dialog"]');
+
+ if (modal && window.getComputedStyle(modal).transform !== "none") {
+ const modalRect = modal.getBoundingClientRect();
+
+ return {
+ top: data.top - modalRect.top,
+ left: data.left - modalRect.left,
+ width: data.width,
+ };
+ }
+ return {
+ top: data.top,
+ left: data.left,
+ width: data.width,
+ };
+}
+
+function nodeDOMAtCoords(coords: { x: number; y: number }) {
+ return document
+ .elementsFromPoint(coords.x, coords.y)
+ .find(
+ (elem: Element) =>
+ elem.parentElement?.matches?.(".ProseMirror") ||
+ elem.matches(
+ [
+ "li",
+ "p:not(:first-child)",
+ "pre",
+ "blockquote",
+ "h1, h2, h3, h4, h5, h6",
+ ].join(", ")
+ )
+ );
+}
+
+function nodePosAtDOM(
+ node: Element,
+ view: EditorView,
+ options: GlobalDragHandleOptions
+) {
+ const boundingRect = node.getBoundingClientRect();
+
+ return view.posAtCoords({
+ left: boundingRect.left + 50 + options.dragHandleWidth,
+ top: boundingRect.top + 1,
+ })?.inside;
+}
+
+function calcNodePos(pos: number, view: EditorView) {
+ const $pos = view.state.doc.resolve(pos);
+ if ($pos.depth > 1) return $pos.before($pos.depth);
+ return pos;
+}
+
+export function DragHandlePlugin(
+ options: GlobalDragHandleOptions & { pluginKey: string }
+) {
+ let listType = "";
+ function handleDragStart(event: DragEvent, view: EditorView) {
+ view.focus();
+
+ if (!event.dataTransfer) return;
+
+ const node = nodeDOMAtCoords({
+ x: event.clientX + 50 + options.dragHandleWidth,
+ y: event.clientY,
+ });
+
+ if (!(node instanceof Element)) return;
+
+ let draggedNodePos = nodePosAtDOM(node, view, options);
+ if (draggedNodePos == null || draggedNodePos < 0) return;
+ draggedNodePos = calcNodePos(draggedNodePos, view);
+
+ const { from, to } = view.state.selection;
+ const diff = from - to;
+
+ const fromSelectionPos = calcNodePos(from, view);
+ let differentNodeSelected = false;
+
+ const nodePos = view.state.doc.resolve(fromSelectionPos);
+
+ // Check if nodePos points to the top level node
+ if (nodePos.node().type.name === "doc") differentNodeSelected = true;
+ else {
+ const nodeSelection = NodeSelection.create(
+ view.state.doc,
+ nodePos.before()
+ );
+
+ // Check if the node where the drag event started is part of the current selection
+ differentNodeSelected = !(
+ draggedNodePos + 1 >= nodeSelection.$from.pos &&
+ draggedNodePos <= nodeSelection.$to.pos
+ );
+ }
+ let selection = view.state.selection;
+ if (
+ !differentNodeSelected &&
+ diff !== 0 &&
+ !(view.state.selection instanceof NodeSelection)
+ ) {
+ const endSelection = NodeSelection.create(view.state.doc, to - 1);
+ selection = TextSelection.create(
+ view.state.doc,
+ draggedNodePos,
+ endSelection.$to.pos
+ );
+ } else {
+ selection = NodeSelection.create(view.state.doc, draggedNodePos);
+
+ // if inline node is selected, e.g mention -> go to the parent node to select the whole node
+ // if table row is selected, go to the parent node to select the whole node
+ if (
+ (selection as NodeSelection).node.type.isInline ||
+ (selection as NodeSelection).node.type.name === "tableRow"
+ ) {
+ let $pos = view.state.doc.resolve(selection.from);
+ selection = NodeSelection.create(view.state.doc, $pos.before());
+ }
+ }
+ view.dispatch(view.state.tr.setSelection(selection));
+
+ // If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
+ if (
+ view.state.selection instanceof NodeSelection &&
+ view.state.selection.node.type.name === "listItem"
+ ) {
+ listType = node.parentElement!.tagName;
+ }
+
+ const slice = view.state.selection.content();
+ const { dom, text } = __serializeForClipboard(view, slice);
+
+ event.dataTransfer.clearData();
+ event.dataTransfer.setData("text/html", dom.innerHTML);
+ event.dataTransfer.setData("text/plain", text);
+ event.dataTransfer.effectAllowed = "copyMove";
+
+ event.dataTransfer.setDragImage(node, 0, 0);
+
+ view.dragging = { slice, move: event.ctrlKey };
+ }
+
+ let dragHandleElement: HTMLElement | null = null;
+
+ function hideDragHandle() {
+ if (dragHandleElement) {
+ dragHandleElement.classList.add("hide");
+ }
+ }
+
+ function showDragHandle() {
+ if (dragHandleElement) {
+ dragHandleElement.classList.remove("hide");
+ }
+ }
+
+ function hideHandleOnEditorOut(event: MouseEvent) {
+ if (event.target instanceof Element) {
+ const isInsideEditor = !!event.target.closest(".tiptap.ProseMirror");
+ const isHandle =
+ !!event.target.attributes.getNamedItem("data-drag-handle");
+ if (isInsideEditor || isHandle) return;
+ }
+ hideDragHandle();
+ }
+
+ return new Plugin({
+ key: new PluginKey(options.pluginKey),
+ view: (view) => {
+ const handleBySelector = options.dragHandleSelector
+ ? document.querySelector
(options.dragHandleSelector)
+ : null;
+ dragHandleElement = handleBySelector ?? document.createElement("div");
+ dragHandleElement.draggable = true;
+ dragHandleElement.dataset.dragHandle = "";
+ dragHandleElement.classList.add("drag-handle");
+
+ function onDragHandleDragStart(e: DragEvent) {
+ handleDragStart(e, view);
+ }
+
+ dragHandleElement.addEventListener("dragstart", onDragHandleDragStart);
+
+ function onDragHandleDrag(e: DragEvent) {
+ hideDragHandle();
+ let scrollY = window.scrollY;
+ if (e.clientY < options.scrollTreshold) {
+ window.scrollTo({ top: scrollY - 30, behavior: "smooth" });
+ } else if (window.innerHeight - e.clientY < options.scrollTreshold) {
+ window.scrollTo({ top: scrollY + 30, behavior: "smooth" });
+ }
+ }
+
+ dragHandleElement.addEventListener("drag", onDragHandleDrag);
+
+ hideDragHandle();
+
+ if (!handleBySelector) {
+ view?.dom?.parentElement?.appendChild(dragHandleElement);
+ }
+ view?.dom?.parentElement?.parentElement?.addEventListener(
+ "mouseleave",
+ hideHandleOnEditorOut
+ );
+
+ return {
+ destroy: () => {
+ if (!handleBySelector) {
+ dragHandleElement?.remove?.();
+ }
+ dragHandleElement?.removeEventListener("drag", onDragHandleDrag);
+ dragHandleElement?.removeEventListener(
+ "dragstart",
+ onDragHandleDragStart
+ );
+ dragHandleElement = null;
+ view?.dom?.parentElement?.parentElement?.removeEventListener(
+ "mouseleave",
+ hideHandleOnEditorOut
+ );
+ },
+ };
+ },
+ props: {
+ handleDOMEvents: {
+ mousemove: (view, event) => {
+ if (!view.editable) {
+ return;
+ }
+
+ const node = nodeDOMAtCoords({
+ x: event.clientX + 50 + options.dragHandleWidth,
+ y: event.clientY,
+ });
+
+ const notDragging = node?.closest(".not-draggable");
+ const excludedTagList = options.excludedTags
+ .concat(["ol", "ul"])
+ .join(", ");
+
+ if (
+ !(node instanceof Element) ||
+ node.matches(excludedTagList) ||
+ notDragging
+ ) {
+ hideDragHandle();
+ return;
+ }
+
+ const compStyle = window.getComputedStyle(node);
+ const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
+ const lineHeight = isNaN(parsedLineHeight)
+ ? parseInt(compStyle.fontSize) * 1.2
+ : parsedLineHeight;
+ const paddingTop = parseInt(compStyle.paddingTop, 10);
+
+ const rect = absoluteRect(node);
+
+ rect.top += (lineHeight - 24) / 2;
+ rect.top += paddingTop;
+ // Li markers
+ if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
+ rect.left -= options.dragHandleWidth;
+ }
+ rect.width = options.dragHandleWidth;
+
+ if (!dragHandleElement) return;
+
+ dragHandleElement.style.left = `${rect.left - rect.width}px`;
+ dragHandleElement.style.top = `${rect.top}px`;
+ showDragHandle();
+ },
+ keydown: () => {
+ hideDragHandle();
+ },
+ mousewheel: () => {
+ hideDragHandle();
+ },
+ // dragging class is used for CSS
+ dragstart: (view) => {
+ view.dom.classList.add("dragging");
+ },
+ drop: (view, event) => {
+ view.dom.classList.remove("dragging");
+ hideDragHandle();
+ let droppedNode: Node | null = null;
+ const dropPos = view.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+
+ if (!dropPos) return;
+
+ if (view.state.selection instanceof NodeSelection) {
+ droppedNode = view.state.selection.node;
+ }
+ if (!droppedNode) return;
+
+ const resolvedPos = view.state.doc.resolve(dropPos.pos);
+
+ const isDroppedInsideList =
+ resolvedPos.parent.type.name === "listItem";
+
+ // If the selected node is a list item and is not dropped inside a list, we need to wrap it inside tag otherwise ol list items will be transformed into ul list item when dropped
+ if (
+ view.state.selection instanceof NodeSelection &&
+ view.state.selection.node.type.name === "listItem" &&
+ !isDroppedInsideList &&
+ listType == "OL"
+ ) {
+ const newList = view.state.schema.nodes.orderedList?.createAndFill(
+ null,
+ droppedNode
+ );
+ const slice = new Slice(Fragment.from(newList), 0, 0);
+ view.dragging = { slice, move: event.ctrlKey };
+ }
+ },
+ dragend: (view) => {
+ view.dom.classList.remove("dragging");
+ },
+ },
+ },
+ });
+}
+
+const GlobalDragHandle = Extension.create({
+ name: "globalDragHandle",
+
+ addOptions() {
+ return {
+ dragHandleWidth: 20,
+ scrollTreshold: 100,
+ excludedTags: [],
+ };
+ },
+
+ addProseMirrorPlugins() {
+ return [
+ DragHandlePlugin({
+ pluginKey: "globalDragHandle",
+ dragHandleWidth: this.options.dragHandleWidth,
+ scrollTreshold: this.options.scrollTreshold,
+ dragHandleSelector: this.options.dragHandleSelector,
+ excludedTags: this.options.excludedTags,
+ }),
+ ];
+ },
+});
+
+export default GlobalDragHandle;
diff --git a/packages/email-editor/src/extensions/index.ts b/packages/email-editor/src/extensions/index.ts
index 10bd2e1..4587c2b 100644
--- a/packages/email-editor/src/extensions/index.ts
+++ b/packages/email-editor/src/extensions/index.ts
@@ -10,7 +10,7 @@ import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import Placeholder from "@tiptap/extension-placeholder";
-import GlobalDragHandle from "tiptap-extension-global-drag-handle";
+import GlobalDragHandle from "./dragHandle";
import { ButtonExtension } from "./ButtonExtension";
import { SlashCommand, getSlashCommandSuggestions } from "./SlashCommand";
import { VariableExtension } from "./VariableExtension";
diff --git a/packages/tailwind-config/tailwind.config.ts b/packages/tailwind-config/tailwind.config.ts
index 9be71b7..0782794 100644
--- a/packages/tailwind-config/tailwind.config.ts
+++ b/packages/tailwind-config/tailwind.config.ts
@@ -64,8 +64,8 @@ const config = {
},
},
animation: {
- "accordion-down": "accordion-down 0.2s ease-out",
- "accordion-up": "accordion-up 0.2s ease-out",
+ "accordion-down": "accordion-down 0.4s ease-out",
+ "accordion-up": "accordion-up 0.4s ease-out",
},
// fontFamily: {
// sans: ["var(--font-geist-sans)"],
diff --git a/packages/ui/index.ts b/packages/ui/index.ts
index e2b24ec..6af65a9 100644
--- a/packages/ui/index.ts
+++ b/packages/ui/index.ts
@@ -1,4 +1,4 @@
import { cn } from "./lib/utils";
export { cn };
-export { ThemeProvider } from "next-themes";
+export { ThemeProvider, useTheme } from "next-themes";
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 9dfa9b5..03d94fd 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -30,6 +30,7 @@
},
"dependencies": {
"@hookform/resolvers": "^3.3.4",
+ "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
@@ -44,12 +45,14 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^1.0.0",
+ "framer-motion": "^11.0.24",
"input-otp": "^1.2.4",
"lucide-react": "^0.359.0",
"next-themes": "^0.3.0",
"pnpm": "^8.15.5",
"react-hook-form": "^7.51.3",
"react-syntax-highlighter": "^15.5.0",
+ "recharts": "^2.12.5",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
diff --git a/packages/ui/src/accordion.tsx b/packages/ui/src/accordion.tsx
new file mode 100644
index 0000000..df90277
--- /dev/null
+++ b/packages/ui/src/accordion.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDown } from "lucide-react";
+import { motion } from "framer-motion";
+
+import { cn } from "../lib/utils";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/packages/ui/src/button.tsx b/packages/ui/src/button.tsx
index 7e11802..6f965d8 100644
--- a/packages/ui/src/button.tsx
+++ b/packages/ui/src/button.tsx
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/utils";
const buttonVariants = cva(
- "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
diff --git a/packages/ui/src/charts.tsx b/packages/ui/src/charts.tsx
new file mode 100644
index 0000000..e999660
--- /dev/null
+++ b/packages/ui/src/charts.tsx
@@ -0,0 +1,365 @@
+"use client";
+
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import { cn } from "../lib/utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ );
+};
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"];
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+});
+ChartContainer.displayName = "Chart";
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([_, config]) => config.theme || config.color
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+