chore: format
This commit is contained in:
@@ -102,10 +102,7 @@
|
|||||||
"filesystem": {
|
"filesystem": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "pnpm",
|
"command": "pnpm",
|
||||||
"args": [
|
"args": ["mcp-server-filesystem", "repos/compose"],
|
||||||
"mcp-server-filesystem",
|
|
||||||
"repos/compose"
|
|
||||||
],
|
|
||||||
"env": {}
|
"env": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,6 +34,7 @@
|
|||||||
!.zshenv
|
!.zshenv
|
||||||
!.hushlogin
|
!.hushlogin
|
||||||
!.last_pwd
|
!.last_pwd
|
||||||
|
!biome.json
|
||||||
|
|
||||||
!/.github/
|
!/.github/
|
||||||
!/.github/**
|
!/.github/**
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
"Id": "Ant-Dark",
|
"Id": "Ant-Dark",
|
||||||
"License": "GPL 3+",
|
"License": "GPL 3+",
|
||||||
"Name": "Ant-Dark",
|
"Name": "Ant-Dark",
|
||||||
"ServiceTypes": [
|
"ServiceTypes": ["Plasma/LookAndFeel"],
|
||||||
"Plasma/LookAndFeel"
|
|
||||||
],
|
|
||||||
"Version": "0.1",
|
"Version": "0.1",
|
||||||
"Website": "https://github.com/EliverLara/Ant/tree/master/kde/Dark"
|
"Website": "https://github.com/EliverLara/Ant/tree/master/kde/Dark"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
"Id": "Nordic-bluish",
|
"Id": "Nordic-bluish",
|
||||||
"License": "GPL 3+",
|
"License": "GPL 3+",
|
||||||
"Name": "Nordic-bluish",
|
"Name": "Nordic-bluish",
|
||||||
"ServiceTypes": [
|
"ServiceTypes": ["Plasma/LookAndFeel"],
|
||||||
"Plasma/LookAndFeel"
|
|
||||||
],
|
|
||||||
"Version": "0.1",
|
"Version": "0.1",
|
||||||
"Website": "https://github.com/EliverLara/Nordic"
|
"Website": "https://github.com/EliverLara/Nordic"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
"Id": "Nordic-darker",
|
"Id": "Nordic-darker",
|
||||||
"License": "GPL 3+",
|
"License": "GPL 3+",
|
||||||
"Name": "Nordic-darker",
|
"Name": "Nordic-darker",
|
||||||
"ServiceTypes": [
|
"ServiceTypes": ["Plasma/LookAndFeel"],
|
||||||
"Plasma/LookAndFeel"
|
|
||||||
],
|
|
||||||
"Version": "0.1",
|
"Version": "0.1",
|
||||||
"Website": "https://github.com/EliverLara/Nordic"
|
"Website": "https://github.com/EliverLara/Nordic"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
"Id": "Nordic",
|
"Id": "Nordic",
|
||||||
"License": "GPL 3+",
|
"License": "GPL 3+",
|
||||||
"Name": "Nordic",
|
"Name": "Nordic",
|
||||||
"ServiceTypes": [
|
"ServiceTypes": ["Plasma/LookAndFeel"],
|
||||||
"Plasma/LookAndFeel"
|
|
||||||
],
|
|
||||||
"Version": "0.1",
|
"Version": "0.1",
|
||||||
"Website": "https://github.com/EliverLara/Nordic"
|
"Website": "https://github.com/EliverLara/Nordic"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
function isDark(color) {
|
function isDark(color) {
|
||||||
|
|
||||||
var r = color.r;
|
var r = color.r;
|
||||||
var g = color.g;
|
var g = color.g;
|
||||||
var b = color.b;
|
var b = color.b;
|
||||||
|
|
||||||
// Using the HSP value, determine whether the color is light or dark
|
// Using the HSP value, determine whether the color is light or dark
|
||||||
var colorArray = [r, g , b ].map(v => {
|
var colorArray = [r, g, b].map((v) => {
|
||||||
if (v <= 0.03928) {
|
if (v <= 0.03928) {
|
||||||
return v / 12.92
|
return v / 12.92;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.pow((v + 0.055) / 1.055, 2.4)
|
return Math.pow((v + 0.055) / 1.055, 2.4);
|
||||||
})
|
});
|
||||||
|
|
||||||
var luminance = 0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2]
|
var luminance =
|
||||||
|
0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2];
|
||||||
|
|
||||||
return luminance <= 0.179
|
return luminance <= 0.179;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
function updateBrightness(rootItem, source) {
|
function updateBrightness(rootItem, source) {
|
||||||
if (rootItem.updateScreenBrightnessJob)
|
if (rootItem.updateScreenBrightnessJob) return;
|
||||||
return;
|
|
||||||
|
|
||||||
if (!source.data["PowerDevil"]) {
|
if (!source.data["PowerDevil"]) {
|
||||||
return;
|
return;
|
||||||
@@ -9,7 +8,7 @@ function updateBrightness(rootItem, source) {
|
|||||||
// we don't want passive brightness change send setBrightness call
|
// we don't want passive brightness change send setBrightness call
|
||||||
rootItem.disableBrightnessUpdate = true;
|
rootItem.disableBrightnessUpdate = true;
|
||||||
|
|
||||||
if (typeof source.data["PowerDevil"]["Screen Brightness"] === 'number') {
|
if (typeof source.data["PowerDevil"]["Screen Brightness"] === "number") {
|
||||||
rootItem.screenBrightness = source.data["PowerDevil"]["Screen Brightness"];
|
rootItem.screenBrightness = source.data["PowerDevil"]["Screen Brightness"];
|
||||||
}
|
}
|
||||||
rootItem.disableBrightnessUpdate = false;
|
rootItem.disableBrightnessUpdate = false;
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
function isDark(color) {
|
function isDark(color) {
|
||||||
|
|
||||||
//color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
|
//color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
|
||||||
|
|
||||||
var r = color.r;
|
var r = color.r;
|
||||||
var g = color.g;
|
var g = color.g;
|
||||||
var b = color.b;
|
var b = color.b;
|
||||||
|
|
||||||
|
var colorArray = [r, g, b].map((v) => {
|
||||||
var colorArray = [r, g , b ].map(v => {
|
|
||||||
if (v <= 0.03928) {
|
if (v <= 0.03928) {
|
||||||
return v / 12.92
|
return v / 12.92;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.pow((v + 0.055) / 1.055, 2.4)
|
return Math.pow((v + 0.055) / 1.055, 2.4);
|
||||||
})
|
});
|
||||||
|
|
||||||
var luminance = 0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2]
|
var luminance =
|
||||||
|
0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2];
|
||||||
|
|
||||||
return luminance <= 0.179
|
return luminance <= 0.179;
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,8 @@ function getBtDevice() {
|
|||||||
|
|
||||||
var status = {
|
var status = {
|
||||||
active: false,
|
active: false,
|
||||||
message: ""
|
message: "",
|
||||||
}
|
};
|
||||||
|
|
||||||
for (var i = 0; i < btManager.devices.length; ++i) {
|
for (var i = 0; i < btManager.devices.length; ++i) {
|
||||||
var device = btManager.devices[i];
|
var device = btManager.devices[i];
|
||||||
@@ -34,8 +34,7 @@ function getBtDevice() {
|
|||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleBluetooth()
|
function toggleBluetooth() {
|
||||||
{
|
|
||||||
var enable = !btManager.bluetoothOperational;
|
var enable = !btManager.bluetoothOperational;
|
||||||
btManager.bluetoothBlocked = !enable;
|
btManager.bluetoothBlocked = !enable;
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ function toggleBluetooth()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function checkInhibition() {
|
function checkInhibition() {
|
||||||
var inhibited = false;
|
var inhibited = false;
|
||||||
|
|
||||||
@@ -54,7 +52,7 @@ function checkInhibition() {
|
|||||||
}
|
}
|
||||||
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
|
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
|
||||||
if (!isNaN(inhibitedUntil.getTime())) {
|
if (!isNaN(inhibitedUntil.getTime())) {
|
||||||
inhibited |= (Date.now() < inhibitedUntil.getTime());
|
inhibited |= Date.now() < inhibitedUntil.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationSettings.notificationsInhibitedByApplication) {
|
if (notificationSettings.notificationsInhibitedByApplication) {
|
||||||
@@ -80,10 +78,10 @@ function toggleDnd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var d = new Date();
|
var d = new Date();
|
||||||
d.setYear(d.getFullYear()+1)
|
d.setYear(d.getFullYear() + 1);
|
||||||
|
|
||||||
notificationSettings.notificationsInhibitedUntil = d
|
notificationSettings.notificationsInhibitedUntil = d;
|
||||||
notificationSettings.save()
|
notificationSettings.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeInhibitions() {
|
function revokeInhibitions() {
|
||||||
@@ -112,18 +110,23 @@ function toggleRedshiftInhibition() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function volumePercent(volume) {
|
function volumePercent(volume) {
|
||||||
return volume / Vol.PulseAudio.NormalVolume * 100
|
return (volume / Vol.PulseAudio.NormalVolume) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boundVolume(volume) {
|
function boundVolume(volume) {
|
||||||
return Math.max(Vol.PulseAudio.MinimalVolume, Math.min(volume, Vol.PulseAudio.NormalVolume));
|
return Math.max(
|
||||||
|
Vol.PulseAudio.MinimalVolume,
|
||||||
|
Math.min(volume, Vol.PulseAudio.NormalVolume),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeVolumeByPercent(volumeObject, deltaPercent) {
|
function changeVolumeByPercent(volumeObject, deltaPercent) {
|
||||||
const oldVolume = volumeObject.volume;
|
const oldVolume = volumeObject.volume;
|
||||||
const oldPercent = volumePercent(oldVolume);
|
const oldPercent = volumePercent(oldVolume);
|
||||||
const targetPercent = oldPercent + deltaPercent;
|
const targetPercent = oldPercent + deltaPercent;
|
||||||
const newVolume = boundVolume(Math.round(Vol.PulseAudio.NormalVolume * (targetPercent/100)));
|
const newVolume = boundVolume(
|
||||||
|
Math.round(Vol.PulseAudio.NormalVolume * (targetPercent / 100)),
|
||||||
|
);
|
||||||
const newPercent = volumePercent(newVolume);
|
const newPercent = volumePercent(newVolume);
|
||||||
volumeObject.muted = newPercent == 0;
|
volumeObject.muted = newPercent == 0;
|
||||||
volumeObject.volume = newVolume;
|
volumeObject.volume = newVolume;
|
||||||
@@ -134,7 +137,7 @@ function volIconName(volume, muted, prefix) {
|
|||||||
prefix = "audio-volume";
|
prefix = "audio-volume";
|
||||||
}
|
}
|
||||||
var icon = null;
|
var icon = null;
|
||||||
var percent = volume / Vol.PulseAudio.NormalVolume
|
var percent = volume / Vol.PulseAudio.NormalVolume;
|
||||||
if (percent <= 0.0 || muted) {
|
if (percent <= 0.0 || muted) {
|
||||||
icon = prefix + "-muted";
|
icon = prefix + "-muted";
|
||||||
} else if (percent <= 0.25) {
|
} else if (percent <= 0.25) {
|
||||||
@@ -151,7 +154,9 @@ function getNetworkConnectionName() {
|
|||||||
var status = network.networkStatus.activeConnections;
|
var status = network.networkStatus.activeConnections;
|
||||||
var statusParts;
|
var statusParts;
|
||||||
|
|
||||||
if(isAirplane){ return "On"; }
|
if (isAirplane) {
|
||||||
|
return "On";
|
||||||
|
}
|
||||||
|
|
||||||
if (status && status !== "Disconnected") {
|
if (status && status !== "Disconnected") {
|
||||||
statusParts = status.split(":");
|
statusParts = status.split(":");
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
function listProperty(item) {
|
function listProperty(item) {
|
||||||
for (var p in item)
|
for (var p in item) {
|
||||||
{
|
|
||||||
if (typeof item[p] != "function")
|
if (typeof item[p] != "function")
|
||||||
if(p != "objectName")
|
if (p != "objectName") console.log(p + ":" + item[p]);
|
||||||
console.log(p + ":" + item[p]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -18,9 +18,7 @@
|
|||||||
"Name[de]": "KDE Kontrollzentrum",
|
"Name[de]": "KDE Kontrollzentrum",
|
||||||
"Name[ko]": "KDE 제어 센터",
|
"Name[ko]": "KDE 제어 센터",
|
||||||
"Name[pt_BR]": "Estação de controle KDE",
|
"Name[pt_BR]": "Estação de controle KDE",
|
||||||
"ServiceTypes": [
|
"ServiceTypes": ["Plasma/Applet"],
|
||||||
"Plasma/Applet"
|
|
||||||
],
|
|
||||||
"Version": "0.1.0",
|
"Version": "0.1.0",
|
||||||
"Website": "https://github.com/EliverLara/kde-control-station/tree/plasma6"
|
"Website": "https://github.com/EliverLara/kde-control-station/tree/plasma6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
"postCreateCommand": "dir=/workspaces/ohmyzsh; rm -rf $HOME/.oh-my-zsh && ln -s $dir $HOME/.oh-my-zsh && cp $dir/templates/minimal.zshrc $HOME/.zshrc && chgrp -R 1000 $dir && chmod g-w,o-w $dir",
|
"postCreateCommand": "dir=/workspaces/ohmyzsh; rm -rf $HOME/.oh-my-zsh && ln -s $dir $HOME/.oh-my-zsh && cp $dir/templates/minimal.zshrc $HOME/.zshrc && chgrp -R 1000 $dir && chmod g-w,o-w $dir",
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"codespaces": {
|
"codespaces": {
|
||||||
"openFiles": [
|
"openFiles": ["README.md"]
|
||||||
"README.md"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
/* Custom keyframes for pulse */
|
/* Custom keyframes for pulse */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
|
|||||||
@@ -1,57 +1,65 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from "next";
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from "next/font/google";
|
||||||
import './globals.css'
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Pivoine Docs - Documentation Hub',
|
title: "Pivoine Docs - Documentation Hub",
|
||||||
description: 'Comprehensive documentation hub for all Pivoine projects by Valknar. Explore technical guides, API references, and tutorials.',
|
description:
|
||||||
keywords: ['documentation', 'pivoine', 'valknar', 'developer', 'guides', 'api'],
|
"Comprehensive documentation hub for all Pivoine projects by Valknar. Explore technical guides, API references, and tutorials.",
|
||||||
authors: [{ name: 'Valknar', url: 'https://pivoine.art' }],
|
keywords: [
|
||||||
creator: 'Valknar',
|
"documentation",
|
||||||
manifest: '/manifest.json',
|
"pivoine",
|
||||||
|
"valknar",
|
||||||
|
"developer",
|
||||||
|
"guides",
|
||||||
|
"api",
|
||||||
|
],
|
||||||
|
authors: [{ name: "Valknar", url: "https://pivoine.art" }],
|
||||||
|
creator: "Valknar",
|
||||||
|
manifest: "/manifest.json",
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
{ url: "/favicon.svg", type: "image/svg+xml" },
|
||||||
{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' },
|
{ url: "/icon.svg", type: "image/svg+xml", sizes: "any" },
|
||||||
],
|
],
|
||||||
apple: [
|
apple: [
|
||||||
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
|
{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: 'black-translucent',
|
statusBarStyle: "black-translucent",
|
||||||
title: 'Pivoine Docs',
|
title: "Pivoine Docs",
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: 'website',
|
type: "website",
|
||||||
locale: 'en_US',
|
locale: "en_US",
|
||||||
url: 'https://docs.pivoine.art',
|
url: "https://docs.pivoine.art",
|
||||||
title: 'Pivoine Docs - Documentation Hub',
|
title: "Pivoine Docs - Documentation Hub",
|
||||||
description: 'Comprehensive documentation hub for all Pivoine projects',
|
description: "Comprehensive documentation hub for all Pivoine projects",
|
||||||
siteName: 'Pivoine Docs',
|
siteName: "Pivoine Docs",
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: "summary_large_image",
|
||||||
title: 'Pivoine Docs - Documentation Hub',
|
title: "Pivoine Docs - Documentation Hub",
|
||||||
description: 'Comprehensive documentation hub for all Pivoine projects',
|
description: "Comprehensive documentation hub for all Pivoine projects",
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="scroll-smooth">
|
<html lang="en" className="scroll-smooth">
|
||||||
<body className={inter.className}>{children}</body>
|
<body className={inter.className}>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,56 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from "react";
|
||||||
import { BookOpen, Code2, Globe, ChevronRight, Sparkles, Terminal } from 'lucide-react'
|
import {
|
||||||
import KomposeIcon from '@/components/icons/KomposeIcon'
|
BookOpen,
|
||||||
import { PivoineDocsIcon } from '@/components/icons'
|
Code2,
|
||||||
|
Globe,
|
||||||
|
ChevronRight,
|
||||||
|
Sparkles,
|
||||||
|
Terminal,
|
||||||
|
} from "lucide-react";
|
||||||
|
import KomposeIcon from "@/components/icons/KomposeIcon";
|
||||||
|
import { PivoineDocsIcon } from "@/components/icons";
|
||||||
|
|
||||||
export default function DocsHub() {
|
export default function DocsHub() {
|
||||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
|
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||||
const [isHovering, setIsHovering] = useState<string | null>(null)
|
const [isHovering, setIsHovering] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
setMousePosition({
|
setMousePosition({
|
||||||
x: (e.clientX / window.innerWidth) * 20 - 10,
|
x: (e.clientX / window.innerWidth) * 20 - 10,
|
||||||
y: (e.clientY / window.innerHeight) * 20 - 10,
|
y: (e.clientY / window.innerHeight) * 20 - 10,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
window.addEventListener('mousemove', handleMouseMove)
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
return () => window.removeEventListener('mousemove', handleMouseMove)
|
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const projects = [
|
const projects = [
|
||||||
{
|
{
|
||||||
name: 'Kompose',
|
name: "Kompose",
|
||||||
status: 'Active',
|
status: "Active",
|
||||||
description: 'Comprehensive documentation for Kompose project',
|
description: "Comprehensive documentation for Kompose project",
|
||||||
url: '/kompose',
|
url: "/kompose",
|
||||||
gradient: 'from-violet-500 to-purple-600'
|
gradient: "from-violet-500 to-purple-600",
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
title: "Valknar's Blog",
|
title: "Valknar's Blog",
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
url: 'http://pivoine.art',
|
url: "http://pivoine.art",
|
||||||
gradient: 'from-pink-500 to-rose-600'
|
gradient: "from-pink-500 to-rose-600",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Source Code',
|
title: "Source Code",
|
||||||
icon: Code2,
|
icon: Code2,
|
||||||
url: 'https://code.pivoine.art',
|
url: "https://code.pivoine.art",
|
||||||
gradient: 'from-cyan-500 to-blue-600'
|
gradient: "from-cyan-500 to-blue-600",
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900 text-white overflow-hidden">
|
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900 text-white overflow-hidden">
|
||||||
@@ -53,18 +60,21 @@ export default function DocsHub() {
|
|||||||
className="absolute w-96 h-96 bg-purple-500/20 rounded-full blur-3xl top-0 -left-48 animate-pulse"
|
className="absolute w-96 h-96 bg-purple-500/20 rounded-full blur-3xl top-0 -left-48 animate-pulse"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${mousePosition.x}px, ${mousePosition.y}px)`,
|
transform: `translate(${mousePosition.x}px, ${mousePosition.y}px)`,
|
||||||
transition: 'transform 0.3s ease-out'
|
transition: "transform 0.3s ease-out",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute w-96 h-96 bg-pink-500/20 rounded-full blur-3xl bottom-0 -right-48 animate-pulse"
|
className="absolute w-96 h-96 bg-pink-500/20 rounded-full blur-3xl bottom-0 -right-48 animate-pulse"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${-mousePosition.x}px, ${-mousePosition.y}px)`,
|
transform: `translate(${-mousePosition.x}px, ${-mousePosition.y}px)`,
|
||||||
transition: 'transform 0.3s ease-out',
|
transition: "transform 0.3s ease-out",
|
||||||
animationDelay: '1s'
|
animationDelay: "1s",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
<div
|
||||||
|
className="absolute w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-pulse"
|
||||||
|
style={{ animationDelay: "0.5s" }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
@@ -73,7 +83,11 @@ export default function DocsHub() {
|
|||||||
<header className="text-center mb-20 pt-12">
|
<header className="text-center mb-20 pt-12">
|
||||||
{/* Hero Icon */}
|
{/* Hero Icon */}
|
||||||
<div className="flex justify-center mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<PivoineDocsIcon size="200px" showLabel={false} interactive={true} />
|
<PivoineDocsIcon
|
||||||
|
size="200px"
|
||||||
|
showLabel={false}
|
||||||
|
interactive={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-flex items-center gap-2 mb-6 px-4 py-2 bg-white/5 backdrop-blur-sm rounded-full border border-white/10">
|
<div className="inline-flex items-center gap-2 mb-6 px-4 py-2 bg-white/5 backdrop-blur-sm rounded-full border border-white/10">
|
||||||
@@ -86,7 +100,8 @@ export default function DocsHub() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-gray-300 max-w-2xl mx-auto leading-relaxed">
|
<p className="text-xl text-gray-300 max-w-2xl mx-auto leading-relaxed">
|
||||||
Comprehensive documentation for all projects by <span className="text-purple-400 font-semibold">Valknar</span>.
|
Comprehensive documentation for all projects by{" "}
|
||||||
|
<span className="text-purple-400 font-semibold">Valknar</span>.
|
||||||
Explore technical guides, API references, and tutorials.
|
Explore technical guides, API references, and tutorials.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -107,17 +122,29 @@ export default function DocsHub() {
|
|||||||
onMouseLeave={() => setIsHovering(null)}
|
onMouseLeave={() => setIsHovering(null)}
|
||||||
className="group relative bg-white/5 backdrop-blur-md rounded-2xl p-8 border border-white/10 hover:border-purple-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-purple-500/20"
|
className="group relative bg-white/5 backdrop-blur-md rounded-2xl p-8 border border-white/10 hover:border-purple-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-purple-500/20"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-br opacity-0 group-hover:opacity-10 rounded-2xl transition-opacity duration-300"
|
<div
|
||||||
style={{ background: `linear-gradient(135deg, rgb(168, 85, 247), rgb(147, 51, 234))` }} />
|
className="absolute inset-0 bg-gradient-to-br opacity-0 group-hover:opacity-10 rounded-2xl transition-opacity duration-300"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, rgb(168, 85, 247), rgb(147, 51, 234))`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
{project.name === 'Kompose' ? (
|
{project.name === "Kompose" ? (
|
||||||
<div className={`relative w-14 h-14 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg flex items-center justify-center`}>
|
<div
|
||||||
<KomposeIcon size="36px" interactive={false} className='' />
|
className={`relative w-14 h-14 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg flex items-center justify-center`}
|
||||||
|
>
|
||||||
|
<KomposeIcon
|
||||||
|
size="36px"
|
||||||
|
interactive={false}
|
||||||
|
className=""
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`p-3 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg`}>
|
<div
|
||||||
|
className={`p-3 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg`}
|
||||||
|
>
|
||||||
<BookOpen className="w-8 h-8 text-white" />
|
<BookOpen className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -148,7 +175,9 @@ export default function DocsHub() {
|
|||||||
<div className="p-3 rounded-xl bg-gradient-to-br from-gray-600 to-gray-700 w-fit mb-4">
|
<div className="p-3 rounded-xl bg-gradient-to-br from-gray-600 to-gray-700 w-fit mb-4">
|
||||||
<BookOpen className="w-8 h-8 text-white" />
|
<BookOpen className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold mb-3 text-gray-400">More Projects</h3>
|
<h3 className="text-2xl font-bold mb-3 text-gray-400">
|
||||||
|
More Projects
|
||||||
|
</h3>
|
||||||
<p className="text-gray-500 leading-relaxed">
|
<p className="text-gray-500 leading-relaxed">
|
||||||
Additional documentation sites coming soon...
|
Additional documentation sites coming soon...
|
||||||
</p>
|
</p>
|
||||||
@@ -166,7 +195,7 @@ export default function DocsHub() {
|
|||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
{links.map((link, idx) => {
|
{links.map((link, idx) => {
|
||||||
const Icon = link.icon
|
const Icon = link.icon;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -175,7 +204,9 @@ export default function DocsHub() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group bg-white/5 backdrop-blur-md rounded-2xl p-6 border border-white/10 hover:border-pink-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-pink-500/20 flex items-center gap-4"
|
className="group bg-white/5 backdrop-blur-md rounded-2xl p-6 border border-white/10 hover:border-pink-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-pink-500/20 flex items-center gap-4"
|
||||||
>
|
>
|
||||||
<div className={`p-4 rounded-xl bg-gradient-to-br ${link.gradient} shadow-lg group-hover:scale-110 transition-transform`}>
|
<div
|
||||||
|
className={`p-4 rounded-xl bg-gradient-to-br ${link.gradient} shadow-lg group-hover:scale-110 transition-transform`}
|
||||||
|
>
|
||||||
<Icon className="w-7 h-7 text-white" />
|
<Icon className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -186,7 +217,7 @@ export default function DocsHub() {
|
|||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-pink-400 group-hover:translate-x-1 transition-all" />
|
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-pink-400 group-hover:translate-x-1 transition-all" />
|
||||||
</a>
|
</a>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -194,11 +225,17 @@ export default function DocsHub() {
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="mt-20 pt-8 border-t border-white/10 text-center text-gray-400">
|
<footer className="mt-20 pt-8 border-t border-white/10 text-center text-gray-400">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Crafted with passion by <span className="text-purple-400 font-semibold">Valknar</span> ·
|
Crafted with passion by{" "}
|
||||||
<a href="http://pivoine.art" className="hover:text-purple-300 transition-colors ml-1">pivoine.art</a>
|
<span className="text-purple-400 font-semibold">Valknar</span> ·
|
||||||
|
<a
|
||||||
|
href="http://pivoine.art"
|
||||||
|
className="hover:text-purple-300 transition-colors ml-1"
|
||||||
|
>
|
||||||
|
pivoine.art
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,269 +1,368 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import PivoineDocsIcon from './PivoineDocsIcon'
|
import PivoineDocsIcon from "./PivoineDocsIcon";
|
||||||
|
|
||||||
export default function PivoineIconDemo() {
|
export default function PivoineIconDemo() {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
minHeight: '100vh',
|
style={{
|
||||||
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)',
|
minHeight: "100vh",
|
||||||
padding: '4rem 2rem',
|
background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
|
||||||
color: '#fff'
|
padding: "4rem 2rem",
|
||||||
}}>
|
color: "#fff",
|
||||||
<div style={{
|
}}
|
||||||
maxWidth: '1400px',
|
>
|
||||||
margin: '0 auto'
|
<div
|
||||||
}}>
|
style={{
|
||||||
|
maxWidth: "1400px",
|
||||||
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{ textAlign: 'center', marginBottom: '4rem' }}>
|
<div style={{ textAlign: "center", marginBottom: "4rem" }}>
|
||||||
<h1 style={{
|
<h1
|
||||||
fontSize: '3rem',
|
style={{
|
||||||
fontWeight: 'bold',
|
fontSize: "3rem",
|
||||||
background: 'linear-gradient(135deg, #ec4899, #a855f7, #c084fc)',
|
fontWeight: "bold",
|
||||||
backgroundClip: 'text',
|
background: "linear-gradient(135deg, #ec4899, #a855f7, #c084fc)",
|
||||||
WebkitBackgroundClip: 'text',
|
backgroundClip: "text",
|
||||||
WebkitTextFillColor: 'transparent',
|
WebkitBackgroundClip: "text",
|
||||||
marginBottom: '1rem'
|
WebkitTextFillColor: "transparent",
|
||||||
}}>
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Pivoine Docs Icon
|
Pivoine Docs Icon
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{
|
<p
|
||||||
fontSize: '1.25rem',
|
style={{
|
||||||
color: '#94a3b8',
|
fontSize: "1.25rem",
|
||||||
maxWidth: '600px',
|
color: "#94a3b8",
|
||||||
margin: '0 auto'
|
maxWidth: "600px",
|
||||||
}}>
|
margin: "0 auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
A beautiful animated peony blossom icon with interactive states
|
A beautiful animated peony blossom icon with interactive states
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Showcase */}
|
{/* Main Showcase */}
|
||||||
<div style={{
|
<div
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
display: "grid",
|
||||||
gap: '3rem',
|
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
||||||
marginBottom: '4rem'
|
gap: "3rem",
|
||||||
}}>
|
marginBottom: "4rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Large Interactive */}
|
{/* Large Interactive */}
|
||||||
<div style={{
|
<div
|
||||||
background: 'rgba(255, 255, 255, 0.05)',
|
style={{
|
||||||
borderRadius: '1rem',
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
padding: '2rem',
|
borderRadius: "1rem",
|
||||||
textAlign: 'center',
|
padding: "2rem",
|
||||||
backdropFilter: 'blur(10px)',
|
textAlign: "center",
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
backdropFilter: "blur(10px)",
|
||||||
}}>
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
<h3 style={{ marginBottom: '1.5rem', color: '#f472b6' }}>
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: "1.5rem", color: "#f472b6" }}>
|
||||||
Interactive (Hover & Click)
|
Interactive (Hover & Click)
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex',
|
style={{
|
||||||
justifyContent: 'center',
|
display: "flex",
|
||||||
alignItems: 'center',
|
justifyContent: "center",
|
||||||
minHeight: '320px'
|
alignItems: "center",
|
||||||
}}>
|
minHeight: "320px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PivoineDocsIcon size="280px" />
|
<PivoineDocsIcon size="280px" />
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Hover to bloom • Click to close
|
Hover to bloom • Click to close
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* With Label */}
|
{/* With Label */}
|
||||||
<div style={{
|
<div
|
||||||
background: 'rgba(255, 255, 255, 0.05)',
|
style={{
|
||||||
borderRadius: '1rem',
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
padding: '2rem',
|
borderRadius: "1rem",
|
||||||
textAlign: 'center',
|
padding: "2rem",
|
||||||
backdropFilter: 'blur(10px)',
|
textAlign: "center",
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
backdropFilter: "blur(10px)",
|
||||||
}}>
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
<h3 style={{ marginBottom: '1.5rem', color: '#c084fc' }}>
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: "1.5rem", color: "#c084fc" }}>
|
||||||
With Label
|
With Label
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex',
|
style={{
|
||||||
justifyContent: 'center',
|
display: "flex",
|
||||||
alignItems: 'center',
|
justifyContent: "center",
|
||||||
minHeight: '320px'
|
alignItems: "center",
|
||||||
}}>
|
minHeight: "320px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PivoineDocsIcon size="240px" showLabel />
|
<PivoineDocsIcon size="240px" showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Perfect for hero sections
|
Perfect for hero sections
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Non-Interactive */}
|
{/* Non-Interactive */}
|
||||||
<div style={{
|
<div
|
||||||
background: 'rgba(255, 255, 255, 0.05)',
|
style={{
|
||||||
borderRadius: '1rem',
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
padding: '2rem',
|
borderRadius: "1rem",
|
||||||
textAlign: 'center',
|
padding: "2rem",
|
||||||
backdropFilter: 'blur(10px)',
|
textAlign: "center",
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
backdropFilter: "blur(10px)",
|
||||||
}}>
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
<h3 style={{ marginBottom: '1.5rem', color: '#fb7185' }}>
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginBottom: "1.5rem", color: "#fb7185" }}>
|
||||||
Static (Non-Interactive)
|
Static (Non-Interactive)
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex',
|
style={{
|
||||||
justifyContent: 'center',
|
display: "flex",
|
||||||
alignItems: 'center',
|
justifyContent: "center",
|
||||||
minHeight: '320px'
|
alignItems: "center",
|
||||||
}}>
|
minHeight: "320px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PivoineDocsIcon size="240px" interactive={false} />
|
<PivoineDocsIcon size="240px" interactive={false} />
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Ideal for favicons & PWA icons
|
Ideal for favicons & PWA icons
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Size Variations */}
|
{/* Size Variations */}
|
||||||
<div style={{
|
<div
|
||||||
background: 'rgba(255, 255, 255, 0.05)',
|
style={{
|
||||||
borderRadius: '1rem',
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
padding: '3rem',
|
borderRadius: "1rem",
|
||||||
backdropFilter: 'blur(10px)',
|
padding: "3rem",
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
backdropFilter: "blur(10px)",
|
||||||
marginBottom: '4rem'
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
}}>
|
marginBottom: "4rem",
|
||||||
<h2 style={{
|
}}
|
||||||
fontSize: '2rem',
|
>
|
||||||
fontWeight: 'bold',
|
<h2
|
||||||
marginBottom: '2rem',
|
style={{
|
||||||
textAlign: 'center',
|
fontSize: "2rem",
|
||||||
color: '#f0abfc'
|
fontWeight: "bold",
|
||||||
}}>
|
marginBottom: "2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#f0abfc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Size Variations
|
Size Variations
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex',
|
style={{
|
||||||
justifyContent: 'space-around',
|
display: "flex",
|
||||||
alignItems: 'flex-end',
|
justifyContent: "space-around",
|
||||||
flexWrap: 'wrap',
|
alignItems: "flex-end",
|
||||||
gap: '2rem',
|
flexWrap: "wrap",
|
||||||
padding: '2rem'
|
gap: "2rem",
|
||||||
}}>
|
padding: "2rem",
|
||||||
<div style={{ textAlign: 'center' }}>
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
<PivoineDocsIcon size="64px" />
|
<PivoineDocsIcon size="64px" />
|
||||||
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
|
<p
|
||||||
64px<br />Favicon
|
style={{
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
64px
|
||||||
|
<br />
|
||||||
|
Favicon
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<PivoineDocsIcon size="96px" />
|
<PivoineDocsIcon size="96px" />
|
||||||
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
|
<p
|
||||||
96px<br />Small
|
style={{
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
96px
|
||||||
|
<br />
|
||||||
|
Small
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<PivoineDocsIcon size="128px" />
|
<PivoineDocsIcon size="128px" />
|
||||||
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
|
<p
|
||||||
128px<br />Medium
|
style={{
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
128px
|
||||||
|
<br />
|
||||||
|
Medium
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<PivoineDocsIcon size="192px" />
|
<PivoineDocsIcon size="192px" />
|
||||||
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
|
<p
|
||||||
192px<br />Large
|
style={{
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
192px
|
||||||
|
<br />
|
||||||
|
Large
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'center' }}>
|
<div style={{ textAlign: "center" }}>
|
||||||
<PivoineDocsIcon size="256px" />
|
<PivoineDocsIcon size="256px" />
|
||||||
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}>
|
<p
|
||||||
256px<br />X-Large
|
style={{
|
||||||
|
color: "#94a3b8",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
256px
|
||||||
|
<br />
|
||||||
|
X-Large
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature List */}
|
{/* Feature List */}
|
||||||
<div style={{
|
<div
|
||||||
background: 'rgba(255, 255, 255, 0.05)',
|
style={{
|
||||||
borderRadius: '1rem',
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
padding: '3rem',
|
borderRadius: "1rem",
|
||||||
backdropFilter: 'blur(10px)',
|
padding: "3rem",
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
backdropFilter: "blur(10px)",
|
||||||
}}>
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
<h2 style={{
|
}}
|
||||||
fontSize: '2rem',
|
>
|
||||||
fontWeight: 'bold',
|
<h2
|
||||||
marginBottom: '2rem',
|
style={{
|
||||||
textAlign: 'center',
|
fontSize: "2rem",
|
||||||
color: '#f0abfc'
|
fontWeight: "bold",
|
||||||
}}>
|
marginBottom: "2rem",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#f0abfc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Features
|
Features
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{
|
<div
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
display: "grid",
|
||||||
gap: '2rem'
|
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
|
||||||
}}>
|
gap: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
icon: '🌸',
|
icon: "🌸",
|
||||||
title: 'Realistic Design',
|
title: "Realistic Design",
|
||||||
description: 'Multi-layered peony with natural gradients'
|
description: "Multi-layered peony with natural gradients",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '✨',
|
icon: "✨",
|
||||||
title: 'Smooth Animations',
|
title: "Smooth Animations",
|
||||||
description: 'Gentle breathing in normal state'
|
description: "Gentle breathing in normal state",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🎭',
|
icon: "🎭",
|
||||||
title: 'Interactive States',
|
title: "Interactive States",
|
||||||
description: 'Bloom on hover, close on click'
|
description: "Bloom on hover, close on click",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '💫',
|
icon: "💫",
|
||||||
title: 'Particle Effects',
|
title: "Particle Effects",
|
||||||
description: '12 bloom particles flying around'
|
description: "12 bloom particles flying around",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🎨',
|
icon: "🎨",
|
||||||
title: 'Beautiful Colors',
|
title: "Beautiful Colors",
|
||||||
description: 'Pink to purple gradient palette'
|
description: "Pink to purple gradient palette",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '♿',
|
icon: "♿",
|
||||||
title: 'Accessible',
|
title: "Accessible",
|
||||||
description: 'Reduced motion & touch support'
|
description: "Reduced motion & touch support",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '📱',
|
icon: "📱",
|
||||||
title: 'Responsive',
|
title: "Responsive",
|
||||||
description: 'Works perfectly on all devices'
|
description: "Works perfectly on all devices",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '⚡',
|
icon: "⚡",
|
||||||
title: 'High Performance',
|
title: "High Performance",
|
||||||
description: 'GPU-accelerated CSS animations'
|
description: "GPU-accelerated CSS animations",
|
||||||
}
|
},
|
||||||
].map((feature, i) => (
|
].map((feature, i) => (
|
||||||
<div key={i} style={{
|
<div
|
||||||
padding: '1.5rem',
|
key={i}
|
||||||
background: 'rgba(255, 255, 255, 0.03)',
|
style={{
|
||||||
borderRadius: '0.75rem',
|
padding: "1.5rem",
|
||||||
border: '1px solid rgba(255, 255, 255, 0.08)'
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
}}>
|
borderRadius: "0.75rem",
|
||||||
<div style={{ fontSize: '2rem', marginBottom: '0.75rem' }}>
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "2rem", marginBottom: "0.75rem" }}>
|
||||||
{feature.icon}
|
{feature.icon}
|
||||||
</div>
|
</div>
|
||||||
<h4 style={{
|
<h4
|
||||||
fontSize: '1.125rem',
|
style={{
|
||||||
fontWeight: '600',
|
fontSize: "1.125rem",
|
||||||
marginBottom: '0.5rem',
|
fontWeight: "600",
|
||||||
color: '#fda4af'
|
marginBottom: "0.5rem",
|
||||||
}}>
|
color: "#fda4af",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{feature.title}
|
{feature.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p style={{
|
<p
|
||||||
fontSize: '0.875rem',
|
style={{
|
||||||
color: '#94a3b8'
|
fontSize: "0.875rem",
|
||||||
}}>
|
color: "#94a3b8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{feature.description}
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,30 +371,36 @@ export default function PivoineIconDemo() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage Example */}
|
{/* Usage Example */}
|
||||||
<div style={{
|
<div
|
||||||
marginTop: '4rem',
|
style={{
|
||||||
background: 'rgba(255, 255, 255, 0.05)',
|
marginTop: "4rem",
|
||||||
borderRadius: '1rem',
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
padding: '2rem',
|
borderRadius: "1rem",
|
||||||
backdropFilter: 'blur(10px)',
|
padding: "2rem",
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)'
|
backdropFilter: "blur(10px)",
|
||||||
}}>
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
<h2 style={{
|
}}
|
||||||
fontSize: '1.5rem',
|
>
|
||||||
fontWeight: 'bold',
|
<h2
|
||||||
marginBottom: '1rem',
|
style={{
|
||||||
color: '#f0abfc'
|
fontSize: "1.5rem",
|
||||||
}}>
|
fontWeight: "bold",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
color: "#f0abfc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Quick Start
|
Quick Start
|
||||||
</h2>
|
</h2>
|
||||||
<pre style={{
|
<pre
|
||||||
background: 'rgba(0, 0, 0, 0.3)',
|
style={{
|
||||||
padding: '1.5rem',
|
background: "rgba(0, 0, 0, 0.3)",
|
||||||
borderRadius: '0.5rem',
|
padding: "1.5rem",
|
||||||
overflow: 'auto',
|
borderRadius: "0.5rem",
|
||||||
fontSize: '0.875rem',
|
overflow: "auto",
|
||||||
color: '#e2e8f0'
|
fontSize: "0.875rem",
|
||||||
}}>
|
color: "#e2e8f0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{`import PivoineDocsIcon from '@/components/icons/PivoineDocsIcon'
|
{`import PivoineDocsIcon from '@/components/icons/PivoineDocsIcon'
|
||||||
|
|
||||||
// Basic usage
|
// Basic usage
|
||||||
@@ -310,15 +415,17 @@ export default function PivoineIconDemo() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={{
|
<div
|
||||||
marginTop: '4rem',
|
style={{
|
||||||
textAlign: 'center',
|
marginTop: "4rem",
|
||||||
color: '#64748b',
|
textAlign: "center",
|
||||||
fontSize: '0.875rem'
|
color: "#64748b",
|
||||||
}}>
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<p>Made with 🌸 for beautiful documentation experiences</p>
|
<p>Made with 🌸 for beautiful documentation experiences</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.kompose-icon-wrapper.is-interactive:hover .k-diagonal-bottom {
|
.kompose-icon-wrapper.is-interactive:hover .k-diagonal-bottom {
|
||||||
animation: line-slide-diagonal-bottom 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s;
|
animation: line-slide-diagonal-bottom 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)
|
||||||
|
0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kompose-icon-wrapper.is-interactive:hover .status-dot {
|
.kompose-icon-wrapper.is-interactive:hover .status-dot {
|
||||||
|
|||||||
@@ -1,48 +1,50 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from "react";
|
||||||
import './KomposeIcon.css'
|
import "./KomposeIcon.css";
|
||||||
|
|
||||||
interface KomposeIconProps {
|
interface KomposeIconProps {
|
||||||
size?: string
|
size?: string;
|
||||||
interactive?: boolean
|
interactive?: boolean;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KomposeIcon({
|
export default function KomposeIcon({
|
||||||
size = '192px',
|
size = "192px",
|
||||||
interactive = true,
|
interactive = true,
|
||||||
className = ''
|
className = "",
|
||||||
}: KomposeIconProps) {
|
}: KomposeIconProps) {
|
||||||
const [isClicked, setIsClicked] = useState(false)
|
const [isClicked, setIsClicked] = useState(false);
|
||||||
const [showRipple, setShowRipple] = useState(false)
|
const [showRipple, setShowRipple] = useState(false);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!interactive) return
|
if (!interactive) return;
|
||||||
|
|
||||||
setIsClicked(true)
|
setIsClicked(true);
|
||||||
setShowRipple(true)
|
setShowRipple(true);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsClicked(false)
|
setIsClicked(false);
|
||||||
}, 600)
|
}, 600);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setShowRipple(false)
|
setShowRipple(false);
|
||||||
}, 800)
|
}, 800);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleTouch = (e: React.TouchEvent) => {
|
const handleTouch = (e: React.TouchEvent) => {
|
||||||
if (!interactive) return
|
if (!interactive) return;
|
||||||
handleClick()
|
handleClick();
|
||||||
}
|
};
|
||||||
|
|
||||||
const wrapperClasses = [
|
const wrapperClasses = [
|
||||||
'kompose-icon-wrapper',
|
"kompose-icon-wrapper",
|
||||||
isClicked && 'is-clicked',
|
isClicked && "is-clicked",
|
||||||
interactive && 'is-interactive',
|
interactive && "is-interactive",
|
||||||
className
|
className,
|
||||||
].filter(Boolean).join(' ')
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,23 +60,58 @@ export default function KomposeIcon({
|
|||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="carbon192" x="0" y="0" width="7.68" height="7.68" patternUnits="userSpaceOnUse">
|
<pattern
|
||||||
|
id="carbon192"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="7.68"
|
||||||
|
height="7.68"
|
||||||
|
patternUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
<rect width="7.68" height="7.68" fill="#0a0e27"></rect>
|
<rect width="7.68" height="7.68" fill="#0a0e27"></rect>
|
||||||
<path d="M0,0 L3.84,3.84 M3.84,0 L7.68,3.84 M0,3.84 L3.84,7.68" stroke="#060815" strokeWidth="1.5" opacity="0.5"></path>
|
<path
|
||||||
|
d="M0,0 L3.84,3.84 M3.84,0 L7.68,3.84 M0,3.84 L3.84,7.68"
|
||||||
|
stroke="#060815"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
opacity="0.5"
|
||||||
|
></path>
|
||||||
</pattern>
|
</pattern>
|
||||||
|
|
||||||
<linearGradient id="bgGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="bgGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<stop offset="0%" style={{ stopColor: '#1a1d2e', stopOpacity: 1 }}></stop>
|
<stop
|
||||||
<stop offset="100%" style={{ stopColor: '#0a0e27', stopOpacity: 1 }}></stop>
|
offset="0%"
|
||||||
|
style={{ stopColor: "#1a1d2e", stopOpacity: 1 }}
|
||||||
|
></stop>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
style={{ stopColor: "#0a0e27", stopOpacity: 1 }}
|
||||||
|
></stop>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|
||||||
<linearGradient id="primaryGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient
|
||||||
<stop offset="0%" className="gradient-start" style={{ stopColor: '#00DC82', stopOpacity: 1 }}></stop>
|
id="primaryGrad192"
|
||||||
<stop offset="100%" className="gradient-end" style={{ stopColor: '#00a86b', stopOpacity: 1 }}></stop>
|
x1="0%"
|
||||||
|
y1="0%"
|
||||||
|
x2="100%"
|
||||||
|
y2="100%"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
className="gradient-start"
|
||||||
|
style={{ stopColor: "#00DC82", stopOpacity: 1 }}
|
||||||
|
></stop>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
className="gradient-end"
|
||||||
|
style={{ stopColor: "#00a86b", stopOpacity: 1 }}
|
||||||
|
></stop>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|
||||||
<filter id="glow192">
|
<filter id="glow192">
|
||||||
<feGaussianBlur stdDeviation="6" result="coloredBlur"></feGaussianBlur>
|
<feGaussianBlur
|
||||||
|
stdDeviation="6"
|
||||||
|
result="coloredBlur"
|
||||||
|
></feGaussianBlur>
|
||||||
<feMerge>
|
<feMerge>
|
||||||
<feMergeNode in="coloredBlur"></feMergeNode>
|
<feMergeNode in="coloredBlur"></feMergeNode>
|
||||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||||
@@ -82,7 +119,10 @@ export default function KomposeIcon({
|
|||||||
</filter>
|
</filter>
|
||||||
|
|
||||||
<filter id="intenseglow192">
|
<filter id="intenseglow192">
|
||||||
<feGaussianBlur stdDeviation="12" result="coloredBlur"></feGaussianBlur>
|
<feGaussianBlur
|
||||||
|
stdDeviation="12"
|
||||||
|
result="coloredBlur"
|
||||||
|
></feGaussianBlur>
|
||||||
<feMerge>
|
<feMerge>
|
||||||
<feMergeNode in="coloredBlur"></feMergeNode>
|
<feMergeNode in="coloredBlur"></feMergeNode>
|
||||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||||
@@ -91,29 +131,124 @@ export default function KomposeIcon({
|
|||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<rect className="bg-rect" width="192" height="192" rx="24" fill="url(#bgGrad192)"></rect>
|
<rect
|
||||||
<rect className="carbon-pattern" width="192" height="192" rx="24" fill="url(#carbon192)" opacity="0.4"></rect>
|
className="bg-rect"
|
||||||
|
width="192"
|
||||||
|
height="192"
|
||||||
|
rx="24"
|
||||||
|
fill="url(#bgGrad192)"
|
||||||
|
></rect>
|
||||||
|
<rect
|
||||||
|
className="carbon-pattern"
|
||||||
|
width="192"
|
||||||
|
height="192"
|
||||||
|
rx="24"
|
||||||
|
fill="url(#carbon192)"
|
||||||
|
opacity="0.4"
|
||||||
|
></rect>
|
||||||
|
|
||||||
{/* Stylized K */}
|
{/* Stylized K */}
|
||||||
<g className="k-letter" transform="translate(48, 48)">
|
<g className="k-letter" transform="translate(48, 48)">
|
||||||
<line className="k-line k-vertical" x1="0" y1="0" x2="0" y2="96" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line>
|
<line
|
||||||
<line className="k-line k-diagonal-top" x1="0" y1="48" x2="57.6" y2="0" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line>
|
className="k-line k-vertical"
|
||||||
<line className="k-line k-diagonal-bottom" x1="0" y1="48" x2="57.6" y2="96" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line>
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="96"
|
||||||
|
stroke="url(#primaryGrad192)"
|
||||||
|
strokeWidth="15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
filter="url(#glow192)"
|
||||||
|
></line>
|
||||||
|
<line
|
||||||
|
className="k-line k-diagonal-top"
|
||||||
|
x1="0"
|
||||||
|
y1="48"
|
||||||
|
x2="57.6"
|
||||||
|
y2="0"
|
||||||
|
stroke="url(#primaryGrad192)"
|
||||||
|
strokeWidth="15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
filter="url(#glow192)"
|
||||||
|
></line>
|
||||||
|
<line
|
||||||
|
className="k-line k-diagonal-bottom"
|
||||||
|
x1="0"
|
||||||
|
y1="48"
|
||||||
|
x2="57.6"
|
||||||
|
y2="96"
|
||||||
|
stroke="url(#primaryGrad192)"
|
||||||
|
strokeWidth="15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
filter="url(#glow192)"
|
||||||
|
></line>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Animated status dot */}
|
{/* Animated status dot */}
|
||||||
<circle className="status-dot" cx="163.2" cy="163.2" r="11.52" fill="#00DC82" opacity="0.9"></circle>
|
<circle
|
||||||
<circle className="status-ring" cx="163.2" cy="163.2" r="17.28" fill="none" stroke="#00DC82" strokeWidth="3" opacity="0.3"></circle>
|
className="status-dot"
|
||||||
|
cx="163.2"
|
||||||
|
cy="163.2"
|
||||||
|
r="11.52"
|
||||||
|
fill="#00DC82"
|
||||||
|
opacity="0.9"
|
||||||
|
></circle>
|
||||||
|
<circle
|
||||||
|
className="status-ring"
|
||||||
|
cx="163.2"
|
||||||
|
cy="163.2"
|
||||||
|
r="17.28"
|
||||||
|
fill="none"
|
||||||
|
stroke="#00DC82"
|
||||||
|
strokeWidth="3"
|
||||||
|
opacity="0.3"
|
||||||
|
></circle>
|
||||||
|
|
||||||
{/* Tech corners */}
|
{/* Tech corners */}
|
||||||
<line className="corner corner-tl-h" x1="15.36" y1="15.36" x2="28.8" y2="15.36" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line>
|
<line
|
||||||
<line className="corner corner-tl-v" x1="15.36" y1="15.36" x2="15.36" y2="28.8" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line>
|
className="corner corner-tl-h"
|
||||||
<line className="corner corner-tr-h" x1="176.64" y1="15.36" x2="163.2" y2="15.36" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line>
|
x1="15.36"
|
||||||
<line className="corner corner-tr-v" x1="176.64" y1="15.36" x2="176.64" y2="28.8" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line>
|
y1="15.36"
|
||||||
|
x2="28.8"
|
||||||
|
y2="15.36"
|
||||||
|
stroke="#00DC82"
|
||||||
|
strokeWidth="3"
|
||||||
|
opacity="0.4"
|
||||||
|
></line>
|
||||||
|
<line
|
||||||
|
className="corner corner-tl-v"
|
||||||
|
x1="15.36"
|
||||||
|
y1="15.36"
|
||||||
|
x2="15.36"
|
||||||
|
y2="28.8"
|
||||||
|
stroke="#00DC82"
|
||||||
|
strokeWidth="3"
|
||||||
|
opacity="0.4"
|
||||||
|
></line>
|
||||||
|
<line
|
||||||
|
className="corner corner-tr-h"
|
||||||
|
x1="176.64"
|
||||||
|
y1="15.36"
|
||||||
|
x2="163.2"
|
||||||
|
y2="15.36"
|
||||||
|
stroke="#00DC82"
|
||||||
|
strokeWidth="3"
|
||||||
|
opacity="0.4"
|
||||||
|
></line>
|
||||||
|
<line
|
||||||
|
className="corner corner-tr-v"
|
||||||
|
x1="176.64"
|
||||||
|
y1="15.36"
|
||||||
|
x2="176.64"
|
||||||
|
y2="28.8"
|
||||||
|
stroke="#00DC82"
|
||||||
|
strokeWidth="3"
|
||||||
|
opacity="0.4"
|
||||||
|
></line>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Ripple effect container */}
|
{/* Ripple effect container */}
|
||||||
{showRipple && <div className="ripple"></div>}
|
{showRipple && <div className="ripple"></div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,14 +99,30 @@
|
|||||||
animation: stamen-pulse 3s ease-in-out infinite;
|
animation: stamen-pulse 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stamen-0 { animation-delay: 0s; }
|
.stamen-0 {
|
||||||
.stamen-1 { animation-delay: 0.2s; }
|
animation-delay: 0s;
|
||||||
.stamen-2 { animation-delay: 0.4s; }
|
}
|
||||||
.stamen-3 { animation-delay: 0.6s; }
|
.stamen-1 {
|
||||||
.stamen-4 { animation-delay: 0.8s; }
|
animation-delay: 0.2s;
|
||||||
.stamen-5 { animation-delay: 1s; }
|
}
|
||||||
.stamen-6 { animation-delay: 1.2s; }
|
.stamen-2 {
|
||||||
.stamen-7 { animation-delay: 1.4s; }
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
.stamen-3 {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
.stamen-4 {
|
||||||
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
.stamen-5 {
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
.stamen-6 {
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
.stamen-7 {
|
||||||
|
animation-delay: 1.4s;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sparkles twinkle */
|
/* Sparkles twinkle */
|
||||||
.sparkle {
|
.sparkle {
|
||||||
@@ -114,14 +130,30 @@
|
|||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sparkle-1 { animation-delay: 0s; }
|
.sparkle-1 {
|
||||||
.sparkle-2 { animation-delay: 0.4s; }
|
animation-delay: 0s;
|
||||||
.sparkle-3 { animation-delay: 0.8s; }
|
}
|
||||||
.sparkle-4 { animation-delay: 1.2s; }
|
.sparkle-2 {
|
||||||
.sparkle-5 { animation-delay: 1.6s; }
|
animation-delay: 0.4s;
|
||||||
.sparkle-6 { animation-delay: 2s; }
|
}
|
||||||
.sparkle-7 { animation-delay: 2.4s; }
|
.sparkle-3 {
|
||||||
.sparkle-8 { animation-delay: 2.8s; }
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
.sparkle-4 {
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
.sparkle-5 {
|
||||||
|
animation-delay: 1.6s;
|
||||||
|
}
|
||||||
|
.sparkle-6 {
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
.sparkle-7 {
|
||||||
|
animation-delay: 2.4s;
|
||||||
|
}
|
||||||
|
.sparkle-8 {
|
||||||
|
animation-delay: 2.8s;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pages subtle floating */
|
/* Pages subtle floating */
|
||||||
.page {
|
.page {
|
||||||
@@ -129,20 +161,36 @@
|
|||||||
animation: page-float 4s ease-in-out infinite;
|
animation: page-float 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-1 { animation-delay: 0s; }
|
.page-1 {
|
||||||
.page-2 { animation-delay: 0.3s; }
|
animation-delay: 0s;
|
||||||
.page-3 { animation-delay: 0.6s; }
|
}
|
||||||
|
.page-2 {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
.page-3 {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
/* Text lines subtle shimmer */
|
/* Text lines subtle shimmer */
|
||||||
.text-line {
|
.text-line {
|
||||||
animation: text-shimmer 4s ease-in-out infinite;
|
animation: text-shimmer 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-1 { animation-delay: 0s; }
|
.line-1 {
|
||||||
.line-2 { animation-delay: 0.3s; }
|
animation-delay: 0s;
|
||||||
.line-3 { animation-delay: 0.6s; }
|
}
|
||||||
.line-4 { animation-delay: 0.9s; }
|
.line-2 {
|
||||||
.line-5 { animation-delay: 1.2s; }
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
.line-3 {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
.line-4 {
|
||||||
|
animation-delay: 0.9s;
|
||||||
|
}
|
||||||
|
.line-5 {
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
/* Bloom particles hidden in normal state */
|
/* Bloom particles hidden in normal state */
|
||||||
.bloom-particle {
|
.bloom-particle {
|
||||||
@@ -179,7 +227,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pivoine-docs-icon-wrapper.is-interactive.is-hovered .inner-petal {
|
.pivoine-docs-icon-wrapper.is-interactive.is-hovered .inner-petal {
|
||||||
transform: scale(1.0) translateX(16px);
|
transform: scale(1) translateX(16px);
|
||||||
opacity: 0.95;
|
opacity: 0.95;
|
||||||
filter: url(#petal-glow);
|
filter: url(#petal-glow);
|
||||||
animation: petal-bloom-hover 2s ease-in-out infinite 0.4s;
|
animation: petal-bloom-hover 2s ease-in-out infinite 0.4s;
|
||||||
@@ -273,7 +321,8 @@
|
|||||||
|
|
||||||
/* Normal State Animations */
|
/* Normal State Animations */
|
||||||
@keyframes bg-breathe {
|
@keyframes bg-breathe {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 0.06;
|
opacity: 0.06;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
@@ -284,7 +333,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes petal-breathe {
|
@keyframes petal-breathe {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(0.3) translateX(8px);
|
transform: scale(0.3) translateX(8px);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -293,7 +343,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes petal-float {
|
@keyframes petal-float {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(0.75) translateX(20px) translateY(0);
|
transform: scale(0.75) translateX(20px) translateY(0);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -302,7 +353,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes center-breathe {
|
@keyframes center-breathe {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -313,7 +365,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes center-breathe-inner {
|
@keyframes center-breathe-inner {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
@@ -333,7 +386,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes stamen-pulse {
|
@keyframes stamen-pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
@@ -344,7 +398,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sparkle-twinkle {
|
@keyframes sparkle-twinkle {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(0.8);
|
transform: scale(0.8);
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
@@ -355,7 +410,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes page-float {
|
@keyframes page-float {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: translateY(0) rotate(0deg);
|
transform: translateY(0) rotate(0deg);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -364,7 +420,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes text-shimmer {
|
@keyframes text-shimmer {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -374,7 +431,8 @@
|
|||||||
|
|
||||||
/* Hover State Animations */
|
/* Hover State Animations */
|
||||||
@keyframes petal-bloom-hover {
|
@keyframes petal-bloom-hover {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(0.55) translateX(38px) rotate(0deg);
|
transform: scale(0.55) translateX(38px) rotate(0deg);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -383,7 +441,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes center-bloom-glow {
|
@keyframes center-bloom-glow {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1.3);
|
transform: scale(1.3);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -394,7 +453,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes center-bloom-inner {
|
@keyframes center-bloom-inner {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1.4);
|
transform: scale(1.4);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -405,7 +465,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes stamen-dance {
|
@keyframes stamen-dance {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1) translateY(0);
|
transform: scale(1) translateY(0);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -437,8 +498,7 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
transform:
|
transform: translate(
|
||||||
translate(
|
|
||||||
calc(cos(var(--particle-angle)) * var(--particle-distance)),
|
calc(cos(var(--particle-angle)) * var(--particle-distance)),
|
||||||
calc(sin(var(--particle-angle)) * var(--particle-distance))
|
calc(sin(var(--particle-angle)) * var(--particle-distance))
|
||||||
)
|
)
|
||||||
@@ -449,8 +509,7 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform:
|
transform: translate(
|
||||||
translate(
|
|
||||||
calc(cos(var(--particle-angle)) * var(--particle-distance) * 1.5),
|
calc(cos(var(--particle-angle)) * var(--particle-distance) * 1.5),
|
||||||
calc(sin(var(--particle-angle)) * var(--particle-distance) * 1.5)
|
calc(sin(var(--particle-angle)) * var(--particle-distance) * 1.5)
|
||||||
)
|
)
|
||||||
@@ -460,7 +519,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bg-bloom-glow {
|
@keyframes bg-bloom-glow {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 0.15;
|
opacity: 0.15;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
@@ -499,7 +559,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes icon-pulse {
|
@keyframes icon-pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -603,8 +664,7 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
30% {
|
30% {
|
||||||
transform:
|
transform: translate(
|
||||||
translate(
|
|
||||||
calc(cos(var(--particle-angle)) * var(--particle-distance) * 2),
|
calc(cos(var(--particle-angle)) * var(--particle-distance) * 2),
|
||||||
calc(sin(var(--particle-angle)) * var(--particle-distance) * 2)
|
calc(sin(var(--particle-angle)) * var(--particle-distance) * 2)
|
||||||
)
|
)
|
||||||
@@ -612,8 +672,7 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform:
|
transform: translate(
|
||||||
translate(
|
|
||||||
calc(cos(var(--particle-angle)) * var(--particle-distance) * 0.3),
|
calc(cos(var(--particle-angle)) * var(--particle-distance) * 0.3),
|
||||||
calc(sin(var(--particle-angle)) * var(--particle-distance) * 0.3)
|
calc(sin(var(--particle-angle)) * var(--particle-distance) * 0.3)
|
||||||
)
|
)
|
||||||
@@ -641,7 +700,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes label-shimmer {
|
@keyframes label-shimmer {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
filter: brightness(1);
|
filter: brightness(1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -690,7 +750,9 @@
|
|||||||
.label-text,
|
.label-text,
|
||||||
.bg-glow {
|
.bg-glow {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
transition: opacity 0.3s ease, transform 0.3s ease !important;
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
transform 0.3s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pivoine-docs-icon-wrapper.is-interactive.is-hovered {
|
.pivoine-docs-icon-wrapper.is-interactive.is-hovered {
|
||||||
|
|||||||
@@ -1,64 +1,66 @@
|
|||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from "react";
|
||||||
import './PivoineDocsIcon.css'
|
import "./PivoineDocsIcon.css";
|
||||||
|
|
||||||
interface PivoineDocsIconProps {
|
interface PivoineDocsIconProps {
|
||||||
size?: string
|
size?: string;
|
||||||
interactive?: boolean
|
interactive?: boolean;
|
||||||
className?: string
|
className?: string;
|
||||||
showLabel?: boolean
|
showLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PivoineDocsIcon({
|
export default function PivoineDocsIcon({
|
||||||
size = '256px',
|
size = "256px",
|
||||||
interactive = true,
|
interactive = true,
|
||||||
className = '',
|
className = "",
|
||||||
showLabel = false
|
showLabel = false,
|
||||||
}: PivoineDocsIconProps) {
|
}: PivoineDocsIconProps) {
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isClicked, setIsClicked] = useState(false)
|
const [isClicked, setIsClicked] = useState(false);
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
if (!interactive) return
|
if (!interactive) return;
|
||||||
setIsHovered(true)
|
setIsHovered(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
if (!interactive) return
|
if (!interactive) return;
|
||||||
setIsHovered(false)
|
setIsHovered(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!interactive) return
|
if (!interactive) return;
|
||||||
|
|
||||||
setIsClicked(true)
|
setIsClicked(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsClicked(false)
|
setIsClicked(false);
|
||||||
}, 1200)
|
}, 1200);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleTouch = (e: React.TouchEvent) => {
|
const handleTouch = (e: React.TouchEvent) => {
|
||||||
if (!interactive) return
|
if (!interactive) return;
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setIsHovered(true)
|
setIsHovered(true);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
handleClick()
|
handleClick();
|
||||||
}, 50)
|
}, 50);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsHovered(false)
|
setIsHovered(false);
|
||||||
}, 1500)
|
}, 1500);
|
||||||
}
|
};
|
||||||
|
|
||||||
const wrapperClasses = [
|
const wrapperClasses = [
|
||||||
'pivoine-docs-icon-wrapper',
|
"pivoine-docs-icon-wrapper",
|
||||||
isHovered && 'is-hovered',
|
isHovered && "is-hovered",
|
||||||
isClicked && 'is-clicked',
|
isClicked && "is-clicked",
|
||||||
interactive && 'is-interactive',
|
interactive && "is-interactive",
|
||||||
className
|
className,
|
||||||
].filter(Boolean).join(' ')
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
// Generate bloom particles with varied properties
|
// Generate bloom particles with varied properties
|
||||||
const bloomParticles = Array.from({ length: 12 }, (_, i) => ({
|
const bloomParticles = Array.from({ length: 12 }, (_, i) => ({
|
||||||
@@ -67,7 +69,7 @@ export default function PivoineDocsIcon({
|
|||||||
distance: 80 + Math.random() * 20,
|
distance: 80 + Math.random() * 20,
|
||||||
size: 2 + Math.random() * 2,
|
size: 2 + Math.random() * 2,
|
||||||
delay: i * 0.08,
|
delay: i * 0.08,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -76,7 +78,7 @@ export default function PivoineDocsIcon({
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onTouchStart={handleTouch}
|
onTouchStart={handleTouch}
|
||||||
style={{ width: size, height: size, rotate: '5deg' }}
|
style={{ width: size, height: size, rotate: "5deg" }}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="pivoine-docs-icon"
|
className="pivoine-docs-icon"
|
||||||
@@ -87,49 +89,130 @@ export default function PivoineDocsIcon({
|
|||||||
<defs>
|
<defs>
|
||||||
{/* Enhanced Gradients for natural peony colors */}
|
{/* Enhanced Gradients for natural peony colors */}
|
||||||
<radialGradient id="petal-gradient-1" cx="30%" cy="30%">
|
<radialGradient id="petal-gradient-1" cx="30%" cy="30%">
|
||||||
<stop offset="0%" style={{ stopColor: '#fdf4ff', stopOpacity: 1 }} />
|
<stop
|
||||||
<stop offset="40%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} />
|
offset="0%"
|
||||||
<stop offset="70%" style={{ stopColor: '#f0abfc', stopOpacity: 1 }} />
|
style={{ stopColor: "#fdf4ff", stopOpacity: 1 }}
|
||||||
<stop offset="100%" style={{ stopColor: '#d946ef', stopOpacity: 0.95 }} />
|
/>
|
||||||
|
<stop
|
||||||
|
offset="40%"
|
||||||
|
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="70%"
|
||||||
|
style={{ stopColor: "#f0abfc", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
style={{ stopColor: "#d946ef", stopOpacity: 0.95 }}
|
||||||
|
/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
<radialGradient id="petal-gradient-2" cx="30%" cy="30%">
|
<radialGradient id="petal-gradient-2" cx="30%" cy="30%">
|
||||||
<stop offset="0%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} />
|
<stop
|
||||||
<stop offset="40%" style={{ stopColor: '#f3e8ff', stopOpacity: 1 }} />
|
offset="0%"
|
||||||
<stop offset="70%" style={{ stopColor: '#e9d5ff', stopOpacity: 1 }} />
|
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
|
||||||
<stop offset="100%" style={{ stopColor: '#c084fc', stopOpacity: 0.95 }} />
|
/>
|
||||||
|
<stop
|
||||||
|
offset="40%"
|
||||||
|
style={{ stopColor: "#f3e8ff", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="70%"
|
||||||
|
style={{ stopColor: "#e9d5ff", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
style={{ stopColor: "#c084fc", stopOpacity: 0.95 }}
|
||||||
|
/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
<radialGradient id="petal-gradient-3" cx="30%" cy="30%">
|
<radialGradient id="petal-gradient-3" cx="30%" cy="30%">
|
||||||
<stop offset="0%" style={{ stopColor: '#fdf4ff', stopOpacity: 1 }} />
|
<stop
|
||||||
<stop offset="40%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} />
|
offset="0%"
|
||||||
<stop offset="70%" style={{ stopColor: '#f0abfc', stopOpacity: 1 }} />
|
style={{ stopColor: "#fdf4ff", stopOpacity: 1 }}
|
||||||
<stop offset="100%" style={{ stopColor: '#d946ef', stopOpacity: 0.95 }} />
|
/>
|
||||||
|
<stop
|
||||||
|
offset="40%"
|
||||||
|
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="70%"
|
||||||
|
style={{ stopColor: "#f0abfc", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
style={{ stopColor: "#d946ef", stopOpacity: 0.95 }}
|
||||||
|
/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
<radialGradient id="petal-gradient-4" cx="30%" cy="30%">
|
<radialGradient id="petal-gradient-4" cx="30%" cy="30%">
|
||||||
<stop offset="0%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} />
|
<stop
|
||||||
<stop offset="40%" style={{ stopColor: '#f3e8ff', stopOpacity: 1 }} />
|
offset="0%"
|
||||||
<stop offset="70%" style={{ stopColor: '#e9d5ff', stopOpacity: 1 }} />
|
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
|
||||||
<stop offset="100%" style={{ stopColor: '#c084fc', stopOpacity: 0.95 }} />
|
/>
|
||||||
|
<stop
|
||||||
|
offset="40%"
|
||||||
|
style={{ stopColor: "#f3e8ff", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="70%"
|
||||||
|
style={{ stopColor: "#e9d5ff", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
style={{ stopColor: "#c084fc", stopOpacity: 0.95 }}
|
||||||
|
/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
<radialGradient id="center-gradient" cx="50%" cy="50%">
|
<radialGradient id="center-gradient" cx="50%" cy="50%">
|
||||||
<stop offset="0%" style={{ stopColor: '#fef3c7', stopOpacity: 1 }} />
|
<stop
|
||||||
<stop offset="30%" style={{ stopColor: '#fde68a', stopOpacity: 1 }} />
|
offset="0%"
|
||||||
<stop offset="60%" style={{ stopColor: '#fbbf24', stopOpacity: 1 }} />
|
style={{ stopColor: "#fef3c7", stopOpacity: 1 }}
|
||||||
<stop offset="100%" style={{ stopColor: '#f59e0b', stopOpacity: 1 }} />
|
/>
|
||||||
|
<stop
|
||||||
|
offset="30%"
|
||||||
|
style={{ stopColor: "#fde68a", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="60%"
|
||||||
|
style={{ stopColor: "#fbbf24", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
style={{ stopColor: "#f59e0b", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
<radialGradient id="center-inner-gradient" cx="50%" cy="50%">
|
<radialGradient id="center-inner-gradient" cx="50%" cy="50%">
|
||||||
<stop offset="0%" style={{ stopColor: '#fffbeb', stopOpacity: 1 }} />
|
<stop
|
||||||
<stop offset="50%" style={{ stopColor: '#fef3c7', stopOpacity: 1 }} />
|
offset="0%"
|
||||||
<stop offset="100%" style={{ stopColor: '#fde68a', stopOpacity: 1 }} />
|
style={{ stopColor: "#fffbeb", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="50%"
|
||||||
|
style={{ stopColor: "#fef3c7", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
style={{ stopColor: "#fde68a", stopOpacity: 1 }}
|
||||||
|
/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
<linearGradient id="page-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient
|
||||||
<stop offset="0%" style={{ stopColor: '#ffffff', stopOpacity: 0.98 }} />
|
id="page-gradient"
|
||||||
<stop offset="100%" style={{ stopColor: '#f3f4f6', stopOpacity: 0.98 }} />
|
x1="0%"
|
||||||
|
y1="0%"
|
||||||
|
x2="100%"
|
||||||
|
y2="100%"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
style={{ stopColor: "#ffffff", stopOpacity: 0.98 }}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
style={{ stopColor: "#f3f4f6", stopOpacity: 0.98 }}
|
||||||
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|
||||||
{/* Enhanced Filters */}
|
{/* Enhanced Filters */}
|
||||||
@@ -175,7 +258,14 @@ export default function PivoineDocsIcon({
|
|||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Subtle background glow */}
|
{/* Subtle background glow */}
|
||||||
<circle className="bg-glow" cx="128" cy="128" r="120" fill="url(#petal-gradient-3)" opacity="0.08" />
|
<circle
|
||||||
|
className="bg-glow"
|
||||||
|
cx="128"
|
||||||
|
cy="128"
|
||||||
|
r="120"
|
||||||
|
fill="url(#petal-gradient-3)"
|
||||||
|
opacity="0.08"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Outer layer - Large petals (8 petals) */}
|
{/* Outer layer - Large petals (8 petals) */}
|
||||||
<g className="outer-petals">
|
<g className="outer-petals">
|
||||||
@@ -198,7 +288,11 @@ export default function PivoineDocsIcon({
|
|||||||
ry="68"
|
ry="68"
|
||||||
fill={`url(#petal-gradient-${petal.gradient})`}
|
fill={`url(#petal-gradient-${petal.gradient})`}
|
||||||
filter="url(#petal-glow)"
|
filter="url(#petal-glow)"
|
||||||
style={{rotate: `${petal.angle}deg`, width: `${128 * petal.scaleX}px`, height: `${70 * petal.scaleY}px`}}
|
style={{
|
||||||
|
rotate: `${petal.angle}deg`,
|
||||||
|
width: `${128 * petal.scaleX}px`,
|
||||||
|
height: `${70 * petal.scaleY}px`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
@@ -224,7 +318,11 @@ export default function PivoineDocsIcon({
|
|||||||
ry="56"
|
ry="56"
|
||||||
fill={`url(#petal-gradient-${petal.gradient})`}
|
fill={`url(#petal-gradient-${petal.gradient})`}
|
||||||
filter="url(#petal-glow)"
|
filter="url(#petal-glow)"
|
||||||
style={{rotate: `${petal.angle}deg`, width: `${128 * petal.scaleX}px`, height: `${70 * petal.scaleY}px`}}
|
style={{
|
||||||
|
rotate: `${petal.angle}deg`,
|
||||||
|
width: `${128 * petal.scaleX}px`,
|
||||||
|
height: `${70 * petal.scaleY}px`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
@@ -276,9 +374,9 @@ export default function PivoineDocsIcon({
|
|||||||
{/* Center details - tiny stamens */}
|
{/* Center details - tiny stamens */}
|
||||||
<g className="center-stamens">
|
<g className="center-stamens">
|
||||||
{Array.from({ length: 8 }).map((_, i) => {
|
{Array.from({ length: 8 }).map((_, i) => {
|
||||||
const angle = (360 / 8) * i
|
const angle = (360 / 8) * i;
|
||||||
const x = 128 + Math.cos((angle * Math.PI) / 180) * 10
|
const x = 128 + Math.cos((angle * Math.PI) / 180) * 10;
|
||||||
const y = 128 + Math.sin((angle * Math.PI) / 180) * 10
|
const y = 128 + Math.sin((angle * Math.PI) / 180) * 10;
|
||||||
return (
|
return (
|
||||||
<circle
|
<circle
|
||||||
key={`stamen-${i}`}
|
key={`stamen-${i}`}
|
||||||
@@ -289,20 +387,76 @@ export default function PivoineDocsIcon({
|
|||||||
fill="#d97706"
|
fill="#d97706"
|
||||||
opacity="0.8"
|
opacity="0.8"
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Sparkles - ambient magical effect */}
|
{/* Sparkles - ambient magical effect */}
|
||||||
<g className="sparkles">
|
<g className="sparkles">
|
||||||
<circle className="sparkle sparkle-1" cx="180" cy="75" r="3" fill="#fbbf24" filter="url(#sparkle-glow)" />
|
<circle
|
||||||
<circle className="sparkle sparkle-2" cx="76" cy="76" r="2.5" fill="#a855f7" filter="url(#sparkle-glow)" />
|
className="sparkle sparkle-1"
|
||||||
<circle className="sparkle sparkle-3" cx="180" cy="180" r="2.5" fill="#ec4899" filter="url(#sparkle-glow)" />
|
cx="180"
|
||||||
<circle className="sparkle sparkle-4" cx="76" cy="180" r="3" fill="#c026d3" filter="url(#sparkle-glow)" />
|
cy="75"
|
||||||
<circle className="sparkle sparkle-5" cx="128" cy="50" r="2" fill="#f0abfc" filter="url(#sparkle-glow)" />
|
r="3"
|
||||||
<circle className="sparkle sparkle-6" cx="206" cy="128" r="2" fill="#fb7185" filter="url(#sparkle-glow)" />
|
fill="#fbbf24"
|
||||||
<circle className="sparkle sparkle-7" cx="128" cy="206" r="2.5" fill="#fbbf24" filter="url(#sparkle-glow)" />
|
filter="url(#sparkle-glow)"
|
||||||
<circle className="sparkle sparkle-8" cx="50" cy="128" r="2" fill="#c084fc" filter="url(#sparkle-glow)" />
|
/>
|
||||||
|
<circle
|
||||||
|
className="sparkle sparkle-2"
|
||||||
|
cx="76"
|
||||||
|
cy="76"
|
||||||
|
r="2.5"
|
||||||
|
fill="#a855f7"
|
||||||
|
filter="url(#sparkle-glow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="sparkle sparkle-3"
|
||||||
|
cx="180"
|
||||||
|
cy="180"
|
||||||
|
r="2.5"
|
||||||
|
fill="#ec4899"
|
||||||
|
filter="url(#sparkle-glow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="sparkle sparkle-4"
|
||||||
|
cx="76"
|
||||||
|
cy="180"
|
||||||
|
r="3"
|
||||||
|
fill="#c026d3"
|
||||||
|
filter="url(#sparkle-glow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="sparkle sparkle-5"
|
||||||
|
cx="128"
|
||||||
|
cy="50"
|
||||||
|
r="2"
|
||||||
|
fill="#f0abfc"
|
||||||
|
filter="url(#sparkle-glow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="sparkle sparkle-6"
|
||||||
|
cx="206"
|
||||||
|
cy="128"
|
||||||
|
r="2"
|
||||||
|
fill="#fb7185"
|
||||||
|
filter="url(#sparkle-glow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="sparkle sparkle-7"
|
||||||
|
cx="128"
|
||||||
|
cy="206"
|
||||||
|
r="2.5"
|
||||||
|
fill="#fbbf24"
|
||||||
|
filter="url(#sparkle-glow)"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="sparkle sparkle-8"
|
||||||
|
cx="50"
|
||||||
|
cy="128"
|
||||||
|
r="2"
|
||||||
|
fill="#c084fc"
|
||||||
|
filter="url(#sparkle-glow)"
|
||||||
|
/>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{/* Flying bloom particles (visible on hover) */}
|
{/* Flying bloom particles (visible on hover) */}
|
||||||
@@ -317,11 +471,13 @@ export default function PivoineDocsIcon({
|
|||||||
fill={`url(#petal-gradient-${(particle.id % 4) + 1})`}
|
fill={`url(#petal-gradient-${(particle.id % 4) + 1})`}
|
||||||
opacity="0"
|
opacity="0"
|
||||||
filter="url(#sparkle-glow)"
|
filter="url(#sparkle-glow)"
|
||||||
style={{
|
style={
|
||||||
'--particle-angle': `${particle.angle}deg`,
|
{
|
||||||
'--particle-distance': `${particle.distance}px`,
|
"--particle-angle": `${particle.angle}deg`,
|
||||||
'--particle-delay': `${particle.delay}s`,
|
"--particle-distance": `${particle.distance}px`,
|
||||||
} as React.CSSProperties}
|
"--particle-delay": `${particle.delay}s`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
@@ -334,5 +490,5 @@ export default function PivoineDocsIcon({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as KomposeIcon } from './KomposeIcon'
|
export { default as KomposeIcon } from "./KomposeIcon";
|
||||||
export { default as PivoineDocsIcon } from './PivoineDocsIcon'
|
export { default as PivoineDocsIcon } from "./PivoineDocsIcon";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'export',
|
output: "export",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|
||||||
// Next.js 15 uses turbopack by default for dev
|
// Next.js 15 uses turbopack by default for dev
|
||||||
@@ -8,39 +8,39 @@ const nextConfig = {
|
|||||||
|
|
||||||
// Optimize production build
|
// Optimize production build
|
||||||
compiler: {
|
compiler: {
|
||||||
removeConsole: process.env.NODE_ENV === 'production',
|
removeConsole: process.env.NODE_ENV === "production",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Image optimization
|
// Image optimization
|
||||||
images: {
|
images: {
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ["image/avif", "image/webp"],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Headers for security
|
// Headers for security
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
source: '/:path*',
|
source: "/:path*",
|
||||||
headers: [
|
headers: [
|
||||||
{
|
{
|
||||||
key: 'X-DNS-Prefetch-Control',
|
key: "X-DNS-Prefetch-Control",
|
||||||
value: 'on'
|
value: "on",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'X-Frame-Options',
|
key: "X-Frame-Options",
|
||||||
value: 'SAMEORIGIN'
|
value: "SAMEORIGIN",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'X-Content-Type-Options',
|
key: "X-Content-Type-Options",
|
||||||
value: 'nosniff'
|
value: "nosniff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Referrer-Policy',
|
key: "Referrer-Policy",
|
||||||
value: 'origin-when-cross-origin'
|
value: "origin-when-cross-origin",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
// Enable experimental features if needed
|
// Enable experimental features if needed
|
||||||
@@ -50,8 +50,8 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
turbopack: {
|
turbopack: {
|
||||||
root: '.'
|
root: ".",
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig;
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export default {
|
|||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@tailwindcss/postcss": {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -4,60 +4,55 @@
|
|||||||
// gem install scss-lint
|
// gem install scss-lint
|
||||||
|
|
||||||
module.exports = function (grunt) {
|
module.exports = function (grunt) {
|
||||||
'use strict';
|
"use strict";
|
||||||
// Project configuration
|
// Project configuration
|
||||||
grunt.initConfig({
|
grunt.initConfig({
|
||||||
// Metadata
|
// Metadata
|
||||||
pkg: grunt.file.readJSON('package.json'),
|
pkg: grunt.file.readJSON("package.json"),
|
||||||
banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - ' +
|
banner:
|
||||||
|
"/*! <%= pkg.name %> - v<%= pkg.version %> - " +
|
||||||
'<%= grunt.template.today("yyyy-mm-dd") %>\n' +
|
'<%= grunt.template.today("yyyy-mm-dd") %>\n' +
|
||||||
'<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' +
|
'<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' +
|
||||||
'* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' +
|
'* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' +
|
||||||
' Licensed <%= props.license %> */\n',
|
" Licensed <%= props.license %> */\n",
|
||||||
|
|
||||||
webfont: {
|
webfont: {
|
||||||
icons: {
|
icons: {
|
||||||
src: [
|
src: ["icons/sbed/*.svg", "icons/lorc/*.svg"],
|
||||||
'icons/sbed/*.svg',
|
dest: "fonts",
|
||||||
'icons/lorc/*.svg'
|
|
||||||
],
|
|
||||||
dest: 'fonts',
|
|
||||||
options: {
|
options: {
|
||||||
styles: 'font,icon,extra',
|
styles: "font,icon,extra",
|
||||||
fontFilename: 'game-icons',
|
fontFilename: "game-icons",
|
||||||
types: ['eot', 'woff2', 'woff', 'ttf', 'svg'],
|
types: ["eot", "woff2", "woff", "ttf", "svg"],
|
||||||
syntax: 'bootstrap',
|
syntax: "bootstrap",
|
||||||
destCss: 'css',
|
destCss: "css",
|
||||||
destScss: 'scss',
|
destScss: "scss",
|
||||||
templateOptions: {
|
templateOptions: {
|
||||||
baseClass: 'gi',
|
baseClass: "gi",
|
||||||
classPrefix: 'gi-'
|
classPrefix: "gi-",
|
||||||
},
|
},
|
||||||
fontFamilyName: 'GameIcons',
|
fontFamilyName: "GameIcons",
|
||||||
font: 'game-icons',
|
font: "game-icons",
|
||||||
stylesheets: ['css', 'scss'],
|
stylesheets: ["css", "scss"],
|
||||||
fontPathVariables: true,
|
fontPathVariables: true,
|
||||||
htmlDemo: false,
|
htmlDemo: false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
// CSS Min
|
// CSS Min
|
||||||
// =======
|
// =======
|
||||||
cssmin: {
|
cssmin: {
|
||||||
target: {
|
target: {
|
||||||
files: {
|
files: {
|
||||||
'css/game-icons.min.css': 'css/game-icons.css'
|
"css/game-icons.min.css": "css/game-icons.css",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// These plugins provide necessary tasks
|
// These plugins provide necessary tasks
|
||||||
grunt.loadNpmTasks('grunt-webfont');
|
grunt.loadNpmTasks("grunt-webfont");
|
||||||
grunt.loadNpmTasks('grunt-contrib-cssmin');
|
grunt.loadNpmTasks("grunt-contrib-cssmin");
|
||||||
|
|
||||||
grunt.registerTask('default', [
|
grunt.registerTask("default", ["webfont", "cssmin"]);
|
||||||
'webfont',
|
|
||||||
'cssmin'
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|||||||
1
Projects/kompose/.gitignore
vendored
1
Projects/kompose/.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log*
|
*.log*
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,63 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
ui: {
|
ui: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: 'emerald',
|
primary: "emerald",
|
||||||
secondary: 'fuchsia',
|
secondary: "fuchsia",
|
||||||
neutral: 'zinc'
|
neutral: "zinc",
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
slots: {
|
slots: {
|
||||||
root: 'border-t border-default',
|
root: "border-t border-default",
|
||||||
left: 'text-sm text-muted'
|
left: "text-sm text-muted",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
siteName: 'Kompose'
|
siteName: "Kompose",
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
title: '',
|
title: "",
|
||||||
to: '/',
|
to: "/",
|
||||||
logo: {
|
logo: {
|
||||||
alt: '',
|
alt: "",
|
||||||
light: '',
|
light: "",
|
||||||
dark: ''
|
dark: "",
|
||||||
},
|
},
|
||||||
search: true,
|
search: true,
|
||||||
colorMode: true,
|
colorMode: true,
|
||||||
links: [{
|
links: [
|
||||||
'icon': 'i-simple-icons-github',
|
{
|
||||||
'to': 'https://github.com/nuxt-ui-templates/docs',
|
icon: "i-simple-icons-github",
|
||||||
'target': '_blank',
|
to: "https://github.com/nuxt-ui-templates/docs",
|
||||||
'aria-label': 'GitHub'
|
target: "_blank",
|
||||||
}]
|
"aria-label": "GitHub",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
credits: `kompose © Valknar ${new Date().getFullYear()}`,
|
credits: `kompose © Valknar ${new Date().getFullYear()}`,
|
||||||
colorMode: false,
|
colorMode: false,
|
||||||
links: [{
|
links: [
|
||||||
'icon': 'i-simple-icons-x',
|
{
|
||||||
'to': 'https://x.com/bordeaux1981',
|
icon: "i-simple-icons-x",
|
||||||
'target': '_blank',
|
to: "https://x.com/bordeaux1981",
|
||||||
'aria-label': 'Nuxt on X'
|
target: "_blank",
|
||||||
}, {
|
"aria-label": "Nuxt on X",
|
||||||
'icon': 'i-simple-icons-github',
|
},
|
||||||
'to': 'https://github.com/valknarogg',
|
{
|
||||||
'target': '_blank',
|
icon: "i-simple-icons-github",
|
||||||
'aria-label': 'Valknar on GitHub'
|
to: "https://github.com/valknarogg",
|
||||||
}]
|
target: "_blank",
|
||||||
|
"aria-label": "Valknar on GitHub",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
toc: {
|
toc: {
|
||||||
title: 'Table of Contents',
|
title: "Table of Contents",
|
||||||
bottom: {
|
bottom: {
|
||||||
title: 'Community',
|
title: "Community",
|
||||||
edit: 'https://code.pivoine.art/valknar/kompose/src/branch/main/docs/content',
|
edit: "https://code.pivoine.art/valknar/kompose/src/branch/main/docs/content",
|
||||||
links: []
|
links: [],
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { seo } = useAppConfig()
|
const { seo } = useAppConfig();
|
||||||
|
|
||||||
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
|
const { data: navigation } = await useAsyncData("navigation", () =>
|
||||||
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
|
queryCollectionNavigation("docs"),
|
||||||
server: false
|
);
|
||||||
})
|
const { data: files } = useLazyAsyncData(
|
||||||
|
"search",
|
||||||
|
() => queryCollectionSearchSections("docs"),
|
||||||
|
{
|
||||||
|
server: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
meta: [
|
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
|
|
||||||
],
|
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: 'en'
|
lang: "en",
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
titleTemplate: `%s - ${seo?.siteName}`,
|
titleTemplate: `%s - ${seo?.siteName}`,
|
||||||
ogSiteName: seo?.siteName,
|
ogSiteName: seo?.siteName,
|
||||||
twitterCard: 'summary_large_image'
|
twitterCard: "summary_large_image",
|
||||||
})
|
});
|
||||||
|
|
||||||
provide('navigation', navigation)
|
provide("navigation", navigation);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
|
|
||||||
@theme static {
|
@theme static {
|
||||||
--container-8xl: 90rem;
|
--container-8xl: 90rem;
|
||||||
--font-sans: 'Public Sans', sans-serif;
|
--font-sans: "Public Sans", sans-serif;
|
||||||
|
|
||||||
--color-green-50: #EFFDF5;
|
--color-green-50: #effdf5;
|
||||||
--color-green-100: #D9FBE8;
|
--color-green-100: #d9fbe8;
|
||||||
--color-green-200: #B3F5D1;
|
--color-green-200: #b3f5d1;
|
||||||
--color-green-300: #75EDAE;
|
--color-green-300: #75edae;
|
||||||
--color-green-400: #00DC82;
|
--color-green-400: #00dc82;
|
||||||
--color-green-500: #00C16A;
|
--color-green-500: #00c16a;
|
||||||
--color-green-600: #00A155;
|
--color-green-600: #00a155;
|
||||||
--color-green-700: #007F45;
|
--color-green-700: #007f45;
|
||||||
--color-green-800: #016538;
|
--color-green-800: #016538;
|
||||||
--color-green-900: #0A5331;
|
--color-green-900: #0a5331;
|
||||||
--color-green-950: #052E16;
|
--color-green-950: #052e16;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { footer } = useAppConfig()
|
const { footer } = useAppConfig();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ContentNavigationItem } from '@nuxt/content'
|
import type { ContentNavigationItem } from "@nuxt/content";
|
||||||
|
|
||||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
|
||||||
|
|
||||||
const { header } = useAppConfig()
|
const { header } = useAppConfig();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -75,50 +75,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from "vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: string
|
size?: string;
|
||||||
interactive?: boolean
|
interactive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
size: '192px',
|
size: "192px",
|
||||||
interactive: true
|
interactive: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
const isClicked = ref(false)
|
const isClicked = ref(false);
|
||||||
const showRipple = ref(false)
|
const showRipple = ref(false);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!props.interactive) return
|
if (!props.interactive) return;
|
||||||
|
|
||||||
isClicked.value = true
|
isClicked.value = true;
|
||||||
showRipple.value = true
|
showRipple.value = true;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isClicked.value = false
|
isClicked.value = false;
|
||||||
}, 600)
|
}, 600);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showRipple.value = false
|
showRipple.value = false;
|
||||||
}, 800)
|
}, 800);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleHover = () => {
|
const handleHover = () => {
|
||||||
if (!props.interactive) return
|
if (!props.interactive) return;
|
||||||
// Hover animations are handled by CSS
|
// Hover animations are handled by CSS
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleLeave = () => {
|
const handleLeave = () => {
|
||||||
if (!props.interactive) return
|
if (!props.interactive) return;
|
||||||
// Leave animations are handled by CSS
|
// Leave animations are handled by CSS
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleTouch = (e: TouchEvent) => {
|
const handleTouch = (e: TouchEvent) => {
|
||||||
if (!props.interactive) return
|
if (!props.interactive) return;
|
||||||
handleClick()
|
handleClick();
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppIcon from './AppIcon.vue'
|
import AppIcon from "./AppIcon.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
size: {
|
size: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '42px' // Can be: '24px', '32px', '42px', '56px', etc.
|
default: "42px", // Can be: '24px', '32px', '42px', '56px', etc.
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const isHovered = ref(false)
|
const isHovered = ref(false);
|
||||||
|
|
||||||
// Load Google Font
|
// Load Google Font
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== "undefined") {
|
||||||
const link = document.createElement('link')
|
const link = document.createElement("link");
|
||||||
link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@800;900&display=swap'
|
link.href =
|
||||||
link.rel = 'stylesheet'
|
"https://fonts.googleapis.com/css2?family=Inter:wght@800;900&display=swap";
|
||||||
document.head.appendChild(link)
|
link.rel = "stylesheet";
|
||||||
|
document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), {
|
const props = withDefaults(
|
||||||
title: 'title',
|
defineProps<{ title?: string; description?: string; headline?: string }>(),
|
||||||
description: 'description'
|
{
|
||||||
})
|
title: "title",
|
||||||
|
description: "description",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const title = computed(() => (props.title || '').slice(0, 60))
|
const title = computed(() => (props.title || "").slice(0, 60));
|
||||||
const description = computed(() => (props.description || '').slice(0, 200))
|
const description = computed(() => (props.description || "").slice(0, 200));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const toast = useToast()
|
const toast = useToast();
|
||||||
const { copy, copied } = useClipboard()
|
const { copy, copied } = useClipboard();
|
||||||
const site = useSiteConfig()
|
const site = useSiteConfig();
|
||||||
const isCopying = ref(false)
|
const isCopying = ref(false);
|
||||||
console.log(site)
|
console.log(site);
|
||||||
|
|
||||||
const mdPath = computed(() => `${site.url}/raw${route.path}.md`)
|
const mdPath = computed(() => `${site.url}/raw${route.path}.md`);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: 'Copy Markdown link',
|
label: "Copy Markdown link",
|
||||||
icon: 'i-lucide-link',
|
icon: "i-lucide-link",
|
||||||
onSelect() {
|
onSelect() {
|
||||||
copy(mdPath.value)
|
copy(mdPath.value);
|
||||||
toast.add({
|
toast.add({
|
||||||
title: 'Copied to clipboard',
|
title: "Copied to clipboard",
|
||||||
icon: 'i-lucide-check-circle'
|
icon: "i-lucide-check-circle",
|
||||||
})
|
});
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'View as Markdown',
|
label: "View as Markdown",
|
||||||
icon: 'i-simple-icons:markdown',
|
icon: "i-simple-icons:markdown",
|
||||||
target: '_blank',
|
target: "_blank",
|
||||||
to: `/raw${route.path}.md`
|
to: `/raw${route.path}.md`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Open in ChatGPT',
|
label: "Open in ChatGPT",
|
||||||
icon: 'i-simple-icons:openai',
|
icon: "i-simple-icons:openai",
|
||||||
target: '_blank',
|
target: "_blank",
|
||||||
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`
|
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Open in Claude',
|
label: "Open in Claude",
|
||||||
icon: 'i-simple-icons:anthropic',
|
icon: "i-simple-icons:anthropic",
|
||||||
target: '_blank',
|
target: "_blank",
|
||||||
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`
|
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`,
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
async function copyPage() {
|
async function copyPage() {
|
||||||
isCopying.value = true
|
isCopying.value = true;
|
||||||
copy(await $fetch<string>(`/raw${route.path}.md`))
|
copy(await $fetch<string>(`/raw${route.path}.md`));
|
||||||
isCopying.value = false
|
isCopying.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { isLoading } = useLoadingIndicator()
|
const { isLoading } = useLoadingIndicator();
|
||||||
|
|
||||||
const appear = ref(false)
|
const appear = ref(false);
|
||||||
const appeared = ref(false)
|
const appeared = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
appear.value = true
|
appear.value = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
appeared.value = true
|
appeared.value = true;
|
||||||
}, 1000)
|
}, 1000);
|
||||||
}, 0)
|
}, 0);
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,58 +1,67 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Star {
|
interface Star {
|
||||||
x: number
|
x: number;
|
||||||
y: number
|
y: number;
|
||||||
size: number
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
starCount?: number
|
defineProps<{
|
||||||
color?: string
|
starCount?: number;
|
||||||
speed?: 'slow' | 'normal' | 'fast'
|
color?: string;
|
||||||
size?: { min: number, max: number }
|
speed?: "slow" | "normal" | "fast";
|
||||||
}>(), {
|
size?: { min: number; max: number };
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
starCount: 300,
|
starCount: 300,
|
||||||
color: 'var(--ui-primary)',
|
color: "var(--ui-primary)",
|
||||||
speed: 'normal',
|
speed: "normal",
|
||||||
size: () => ({
|
size: () => ({
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 2
|
max: 2,
|
||||||
})
|
}),
|
||||||
})
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Generate random star positions and sizes
|
// Generate random star positions and sizes
|
||||||
const generateStars = (count: number): Star[] => {
|
const generateStars = (count: number): Star[] => {
|
||||||
return Array.from({ length: count }, () => ({
|
return Array.from({ length: count }, () => ({
|
||||||
x: Math.floor(Math.random() * 2000),
|
x: Math.floor(Math.random() * 2000),
|
||||||
y: Math.floor(Math.random() * 2000),
|
y: Math.floor(Math.random() * 2000),
|
||||||
size: typeof props.size === 'number'
|
size:
|
||||||
|
typeof props.size === "number"
|
||||||
? props.size
|
? props.size
|
||||||
: Math.random() * (props.size.max - props.size.min) + props.size.min
|
: Math.random() * (props.size.max - props.size.min) + props.size.min,
|
||||||
}))
|
}));
|
||||||
}
|
};
|
||||||
|
|
||||||
// Define speed configurations once
|
// Define speed configurations once
|
||||||
const speedMap = {
|
const speedMap = {
|
||||||
slow: { duration: 200, opacity: 0.5, ratio: 0.3 },
|
slow: { duration: 200, opacity: 0.5, ratio: 0.3 },
|
||||||
normal: { duration: 150, opacity: 0.75, ratio: 0.3 },
|
normal: { duration: 150, opacity: 0.75, ratio: 0.3 },
|
||||||
fast: { duration: 100, opacity: 1, ratio: 0.4 }
|
fast: { duration: 100, opacity: 1, ratio: 0.4 },
|
||||||
}
|
};
|
||||||
|
|
||||||
// Use a more efficient approach to generate and store stars
|
// Use a more efficient approach to generate and store stars
|
||||||
const stars = useState<{ slow: Star[], normal: Star[], fast: Star[] }>('stars', () => {
|
const stars = useState<{ slow: Star[]; normal: Star[]; fast: Star[] }>(
|
||||||
|
"stars",
|
||||||
|
() => {
|
||||||
return {
|
return {
|
||||||
slow: generateStars(Math.floor(props.starCount * speedMap.slow.ratio)),
|
slow: generateStars(Math.floor(props.starCount * speedMap.slow.ratio)),
|
||||||
normal: generateStars(Math.floor(props.starCount * speedMap.normal.ratio)),
|
normal: generateStars(
|
||||||
fast: generateStars(Math.floor(props.starCount * speedMap.fast.ratio))
|
Math.floor(props.starCount * speedMap.normal.ratio),
|
||||||
}
|
),
|
||||||
})
|
fast: generateStars(Math.floor(props.starCount * speedMap.fast.ratio)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Compute star layers with different speeds and opacities
|
// Compute star layers with different speeds and opacities
|
||||||
const starLayers = computed(() => [
|
const starLayers = computed(() => [
|
||||||
{ stars: stars.value.fast, ...speedMap.fast },
|
{ stars: stars.value.fast, ...speedMap.fast },
|
||||||
{ stars: stars.value.normal, ...speedMap.normal },
|
{ stars: stars.value.normal, ...speedMap.normal },
|
||||||
{ stars: stars.value.slow, ...speedMap.slow }
|
{ stars: stars.value.slow, ...speedMap.slow },
|
||||||
])
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NuxtError } from '#app'
|
import type { NuxtError } from "#app";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
error: NuxtError
|
error: NuxtError;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: 'en'
|
lang: "en",
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: 'Page not found',
|
title: "Page not found",
|
||||||
description: 'We are sorry but this page could not be found.'
|
description: "We are sorry but this page could not be found.",
|
||||||
})
|
});
|
||||||
|
|
||||||
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs'))
|
const { data: navigation } = await useAsyncData("navigation", () =>
|
||||||
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), {
|
queryCollectionNavigation("docs"),
|
||||||
server: false
|
);
|
||||||
})
|
const { data: files } = useLazyAsyncData(
|
||||||
|
"search",
|
||||||
|
() => queryCollectionSearchSections("docs"),
|
||||||
|
{
|
||||||
|
server: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
provide('navigation', navigation)
|
provide("navigation", navigation);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ContentNavigationItem } from '@nuxt/content'
|
import type { ContentNavigationItem } from "@nuxt/content";
|
||||||
|
|
||||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,55 +1,63 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ContentNavigationItem } from '@nuxt/content'
|
import type { ContentNavigationItem } from "@nuxt/content";
|
||||||
import { findPageHeadline } from '@nuxt/content/utils'
|
import { findPageHeadline } from "@nuxt/content/utils";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'docs'
|
layout: "docs",
|
||||||
})
|
});
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const { toc } = useAppConfig()
|
const { toc } = useAppConfig();
|
||||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
|
const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
|
||||||
|
|
||||||
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first())
|
const { data: page } = await useAsyncData(route.path, () =>
|
||||||
|
queryCollection("docs").path(route.path).first(),
|
||||||
|
);
|
||||||
if (!page.value) {
|
if (!page.value) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: "Page not found",
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: surround } = await useAsyncData(`${route.path}-surround`, () => {
|
const { data: surround } = await useAsyncData(`${route.path}-surround`, () => {
|
||||||
return queryCollectionItemSurroundings('docs', route.path, {
|
return queryCollectionItemSurroundings("docs", route.path, {
|
||||||
fields: ['description']
|
fields: ["description"],
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
const title = page.value.seo?.title || page.value.title
|
const title = page.value.seo?.title || page.value.title;
|
||||||
const description = page.value.seo?.description || page.value.description
|
const description = page.value.seo?.description || page.value.description;
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title,
|
title,
|
||||||
ogTitle: title,
|
ogTitle: title,
|
||||||
description,
|
description,
|
||||||
ogDescription: description
|
ogDescription: description,
|
||||||
})
|
});
|
||||||
|
|
||||||
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path))
|
const headline = computed(() =>
|
||||||
|
findPageHeadline(navigation?.value, page.value?.path),
|
||||||
|
);
|
||||||
|
|
||||||
defineOgImageComponent('Docs', {
|
defineOgImageComponent("Docs", {
|
||||||
headline: headline.value
|
headline: headline.value,
|
||||||
})
|
});
|
||||||
|
|
||||||
const links = computed(() => {
|
const links = computed(() => {
|
||||||
const links = []
|
const links = [];
|
||||||
if (toc?.bottom?.edit) {
|
if (toc?.bottom?.edit) {
|
||||||
links.push({
|
links.push({
|
||||||
icon: 'i-lucide-external-link',
|
icon: "i-lucide-external-link",
|
||||||
label: 'Edit this page',
|
label: "Edit this page",
|
||||||
to: `${toc.bottom.edit}/${page?.value?.stem}.${page?.value?.extension}`,
|
to: `${toc.bottom.edit}/${page?.value?.stem}.${page?.value?.extension}`,
|
||||||
target: '_blank'
|
target: "_blank",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...links, ...(toc?.bottom?.links || [])].filter(Boolean)
|
return [...links, ...(toc?.bottom?.links || [])].filter(Boolean);
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
import { defineContentConfig, defineCollection, z } from "@nuxt/content";
|
||||||
|
|
||||||
export default defineContentConfig({
|
export default defineContentConfig({
|
||||||
collections: {
|
collections: {
|
||||||
landing: defineCollection({
|
landing: defineCollection({
|
||||||
type: 'page',
|
type: "page",
|
||||||
source: 'index.md'
|
source: "index.md",
|
||||||
}),
|
}),
|
||||||
docs: defineCollection({
|
docs: defineCollection({
|
||||||
type: 'page',
|
type: "page",
|
||||||
source: {
|
source: {
|
||||||
include: '**',
|
include: "**",
|
||||||
},
|
},
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
links: z.array(z.object({
|
links: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
icon: z.string(),
|
icon: z.string(),
|
||||||
to: z.string(),
|
to: z.string(),
|
||||||
target: z.string().optional()
|
target: z.string().optional(),
|
||||||
})).optional()
|
}),
|
||||||
})
|
)
|
||||||
})
|
.optional(),
|
||||||
}
|
}),
|
||||||
})
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||||
|
|
||||||
export default withNuxt(
|
export default withNuxt(
|
||||||
// Your custom configs here
|
// Your custom configs here
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
app: {
|
app: {
|
||||||
baseURL: '/kompose/',
|
baseURL: "/kompose/",
|
||||||
},
|
},
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxt/eslint',
|
"@nuxt/eslint",
|
||||||
'@nuxt/image',
|
"@nuxt/image",
|
||||||
'@nuxt/ui',
|
"@nuxt/ui",
|
||||||
'@nuxt/content',
|
"@nuxt/content",
|
||||||
'nuxt-og-image',
|
"nuxt-og-image",
|
||||||
'nuxt-llms'
|
"nuxt-llms",
|
||||||
],
|
],
|
||||||
|
|
||||||
// content: {
|
// content: {
|
||||||
@@ -35,69 +35,68 @@ export default defineNuxtConfig({
|
|||||||
// },
|
// },
|
||||||
|
|
||||||
devtools: {
|
devtools: {
|
||||||
enabled: false
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
css: ['~/assets/css/main.css'],
|
css: ["~/assets/css/main.css"],
|
||||||
|
|
||||||
content: {
|
content: {
|
||||||
build: {
|
build: {
|
||||||
markdown: {
|
markdown: {
|
||||||
toc: {
|
toc: {
|
||||||
searchDepth: 1
|
searchDepth: 1,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
compatibilityDate: '2024-07-11',
|
compatibilityDate: "2024-07-11",
|
||||||
|
|
||||||
nitro: {
|
nitro: {
|
||||||
prerender: {
|
prerender: {
|
||||||
routes: [
|
routes: ["/"],
|
||||||
'/'
|
|
||||||
],
|
|
||||||
crawlLinks: true,
|
crawlLinks: true,
|
||||||
autoSubfolderIndex: false
|
autoSubfolderIndex: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
eslint: {
|
eslint: {
|
||||||
config: {
|
config: {
|
||||||
stylistic: {
|
stylistic: {
|
||||||
commaDangle: 'never',
|
commaDangle: "never",
|
||||||
braceStyle: '1tbs'
|
braceStyle: "1tbs",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
icon: {
|
icon: {
|
||||||
provider: 'iconify'
|
provider: "iconify",
|
||||||
},
|
},
|
||||||
|
|
||||||
llms: {
|
llms: {
|
||||||
domain: 'https://docs-template.nuxt.dev/',
|
domain: "https://docs-template.nuxt.dev/",
|
||||||
title: 'Nuxt Docs Template',
|
title: "Nuxt Docs Template",
|
||||||
description: 'A template for building documentation with Nuxt UI and Nuxt Content.',
|
description:
|
||||||
|
"A template for building documentation with Nuxt UI and Nuxt Content.",
|
||||||
full: {
|
full: {
|
||||||
title: 'Nuxt Docs Template - Full Documentation',
|
title: "Nuxt Docs Template - Full Documentation",
|
||||||
description: 'This is the full documentation for the Nuxt Docs Template.'
|
description: "This is the full documentation for the Nuxt Docs Template.",
|
||||||
},
|
},
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: 'Getting Started',
|
title: "Getting Started",
|
||||||
contentCollection: 'docs',
|
contentCollection: "docs",
|
||||||
contentFilters: [
|
contentFilters: [
|
||||||
{ field: 'path', operator: 'LIKE', value: '/getting-started%' }
|
{ field: "path", operator: "LIKE", value: "/getting-started%" },
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Essentials',
|
title: "Essentials",
|
||||||
contentCollection: 'docs',
|
contentCollection: "docs",
|
||||||
contentFilters: [
|
contentFilters: [
|
||||||
{ field: 'path', operator: 'LIKE', value: '/essentials%' }
|
{ field: "path", operator: "LIKE", value: "/essentials%" },
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"extends": [
|
"extends": ["github>nuxt/renovate-config-nuxt"],
|
||||||
"github>nuxt/renovate-config-nuxt"
|
|
||||||
],
|
|
||||||
"lockFileMaintenance": {
|
"lockFileMaintenance": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"packageRules": [{
|
"packageRules": [
|
||||||
|
{
|
||||||
"matchDepTypes": ["resolutions"],
|
"matchDepTypes": ["resolutions"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
}],
|
}
|
||||||
|
],
|
||||||
"postUpdateOptions": ["pnpmDedupe"]
|
"postUpdateOptions": ["pnpmDedupe"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,40 @@
|
|||||||
import { withLeadingSlash } from 'ufo'
|
import { withLeadingSlash } from "ufo";
|
||||||
import { stringify } from 'minimark/stringify'
|
import { stringify } from "minimark/stringify";
|
||||||
import { queryCollection } from '@nuxt/content/nitro'
|
import { queryCollection } from "@nuxt/content/nitro";
|
||||||
import type { Collections } from '@nuxt/content'
|
import type { Collections } from "@nuxt/content";
|
||||||
|
|
||||||
export default eventHandler(async (event) => {
|
export default eventHandler(async (event) => {
|
||||||
const slug = getRouterParams(event)['slug.md']
|
const slug = getRouterParams(event)["slug.md"];
|
||||||
if (!slug?.endsWith('.md')) {
|
if (!slug?.endsWith(".md")) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: "Page not found",
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = withLeadingSlash(slug.replace('.md', ''))
|
const path = withLeadingSlash(slug.replace(".md", ""));
|
||||||
|
|
||||||
const page = await queryCollection(event, 'docs' as keyof Collections).path(path).first()
|
const page = await queryCollection(event, "docs" as keyof Collections)
|
||||||
|
.path(path)
|
||||||
|
.first();
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: "Page not found",
|
||||||
|
fatal: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add title and description to the top of the page if missing
|
// Add title and description to the top of the page if missing
|
||||||
if (page.body.value[0]?.[0] !== 'h1') {
|
if (page.body.value[0]?.[0] !== "h1") {
|
||||||
page.body.value.unshift(['blockquote', {}, page.description])
|
page.body.value.unshift(["blockquote", {}, page.description]);
|
||||||
page.body.value.unshift(['h1', {}, page.title])
|
page.body.value.unshift(["h1", {}, page.title]);
|
||||||
}
|
}
|
||||||
|
|
||||||
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
|
setHeader(event, "Content-Type", "text/markdown; charset=utf-8");
|
||||||
return stringify({ ...page.body, type: 'minimark' }, { format: 'markdown/html' })
|
return stringify(
|
||||||
})
|
{ ...page.body, type: "minimark" },
|
||||||
|
{ format: "markdown/html" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import globals from "globals"
|
import globals from "globals";
|
||||||
import pluginJs from "@eslint/js"
|
import pluginJs from "@eslint/js";
|
||||||
import * as tseslint from "typescript-eslint"
|
import * as tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config({
|
export default tseslint.config({
|
||||||
files: ["src/**/*.{js,mjs,cjs,ts}"],
|
files: ["src/**/*.{js,mjs,cjs,ts}"],
|
||||||
@@ -22,4 +22,4 @@ export default tseslint.config({
|
|||||||
"@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }],
|
"@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }],
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { hashPassword } from "../src/utils/auth"
|
import { hashPassword } from "../src/utils/auth";
|
||||||
import { prisma } from "../src/utils/prisma"
|
import { prisma } from "../src/utils/prisma";
|
||||||
import { SmtpEncryption } from "./client"
|
import { SmtpEncryption } from "./client";
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
if (!(await prisma.organization.findFirst())) {
|
if (!(await prisma.organization.findFirst())) {
|
||||||
@@ -27,7 +27,7 @@ async function seed() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = (
|
const orgId = (
|
||||||
@@ -36,10 +36,10 @@ async function seed() {
|
|||||||
createdAt: "asc",
|
createdAt: "asc",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)?.id
|
)?.id;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
throw new Error("not reachable")
|
throw new Error("not reachable");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await prisma.user.findFirst())) {
|
if (!(await prisma.user.findFirst())) {
|
||||||
@@ -54,7 +54,7 @@ async function seed() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 5000 subscribers
|
// Create 5000 subscribers
|
||||||
@@ -63,17 +63,17 @@ async function seed() {
|
|||||||
email: `subscriber${i + 1}@example.com`,
|
email: `subscriber${i + 1}@example.com`,
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
createdAt: dayjs().subtract(12, "days").toDate(),
|
createdAt: dayjs().subtract(12, "days").toDate(),
|
||||||
}))
|
}));
|
||||||
await prisma.subscriber.createMany({
|
await prisma.subscriber.createMany({
|
||||||
data: subscribers,
|
data: subscribers,
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
})
|
});
|
||||||
// Then 10 more for each day for 10 days
|
// Then 10 more for each day for 10 days
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
for (let d = 0; d < 10; d++) {
|
for (let d = 0; d < 10; d++) {
|
||||||
const day = dayjs(now)
|
const day = dayjs(now)
|
||||||
.subtract(d + 1, "day")
|
.subtract(d + 1, "day")
|
||||||
.toDate()
|
.toDate();
|
||||||
|
|
||||||
const dailySubs = Array.from({ length: 10 }, (_, i) => ({
|
const dailySubs = Array.from({ length: 10 }, (_, i) => ({
|
||||||
name: `DailySub ${d + 1}-${i + 1}`,
|
name: `DailySub ${d + 1}-${i + 1}`,
|
||||||
@@ -81,19 +81,19 @@ async function seed() {
|
|||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
createdAt: day,
|
createdAt: day,
|
||||||
updatedAt: day,
|
updatedAt: day,
|
||||||
}))
|
}));
|
||||||
await prisma.subscriber.createMany({
|
await prisma.subscriber.createMany({
|
||||||
data: dailySubs,
|
data: dailySubs,
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seed()
|
seed()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await prisma.$disconnect()
|
await prisma.$disconnect();
|
||||||
})
|
})
|
||||||
.catch(async (e) => {
|
.catch(async (e) => {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
await prisma.$disconnect()
|
await prisma.$disconnect();
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import express, { NextFunction } from "express"
|
import express, { NextFunction } from "express";
|
||||||
|
|
||||||
export const authenticateApiKey = async (
|
export const authenticateApiKey = async (
|
||||||
req: express.Request,
|
req: express.Request,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
next: NextFunction
|
next: NextFunction,
|
||||||
) => {
|
) => {
|
||||||
const apiKey = req.header("x-api-key")
|
const apiKey = req.header("x-api-key");
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
res.status(401).json({ error: "Missing API Key" })
|
res.status(401).json({ error: "Missing API Key" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keyRecord = await prisma.apiKey.findUnique({
|
const keyRecord = await prisma.apiKey.findUnique({
|
||||||
where: { key: apiKey },
|
where: { key: apiKey },
|
||||||
select: { id: true, Organization: true },
|
select: { id: true, Organization: true },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!keyRecord) {
|
if (!keyRecord) {
|
||||||
res.status(401).json({ error: "Invalid API Key" })
|
res.status(401).json({ error: "Invalid API Key" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update lastUsed timestamp asynchronously, don't await
|
// Update lastUsed timestamp asynchronously, don't await
|
||||||
@@ -33,14 +33,14 @@ export const authenticateApiKey = async (
|
|||||||
// Log the error but don't block the request
|
// Log the error but don't block the request
|
||||||
console.error(
|
console.error(
|
||||||
"Failed to update API key lastUsed timestamp",
|
"Failed to update API key lastUsed timestamp",
|
||||||
updateError
|
updateError,
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
req.organization = keyRecord.Organization
|
req.organization = keyRecord.Organization;
|
||||||
next()
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error validating API key", error)
|
console.error("Error validating API key", error);
|
||||||
res.status(500).json({ error: "Server error" })
|
res.status(500).json({ error: "Server error" });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import express from "express"
|
import express from "express";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { authenticateApiKey } from "./middleware"
|
import { authenticateApiKey } from "./middleware";
|
||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { Prisma } from "../../prisma/client"
|
import { Prisma } from "../../prisma/client";
|
||||||
import crypto from "crypto"
|
import crypto from "crypto";
|
||||||
import { Mailer } from "../lib/Mailer"
|
import { Mailer } from "../lib/Mailer";
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises";
|
||||||
import path from "path"
|
import path from "path";
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export const apiRouter = express.Router()
|
export const apiRouter = express.Router();
|
||||||
|
|
||||||
apiRouter.use(authenticateApiKey)
|
apiRouter.use(authenticateApiKey);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -123,13 +123,13 @@ apiRouter.post("/subscribers", async (req, res) => {
|
|||||||
emailVerified: z.boolean().optional(),
|
emailVerified: z.boolean().optional(),
|
||||||
metadata: z.record(z.string(), z.string()).optional(),
|
metadata: z.record(z.string(), z.string()).optional(),
|
||||||
})
|
})
|
||||||
.safeParse(req.body)
|
.safeParse(req.body);
|
||||||
|
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: validationError.issues[0]?.message || "Invalid input data",
|
error: validationError.issues[0]?.message || "Invalid input data",
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -139,7 +139,7 @@ apiRouter.post("/subscribers", async (req, res) => {
|
|||||||
doubleOptIn,
|
doubleOptIn,
|
||||||
emailVerified,
|
emailVerified,
|
||||||
metadata: newMetadata,
|
metadata: newMetadata,
|
||||||
} = body
|
} = body;
|
||||||
|
|
||||||
const existingLists = await prisma.list.findMany({
|
const existingLists = await prisma.list.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -148,13 +148,15 @@ apiRouter.post("/subscribers", async (req, res) => {
|
|||||||
},
|
},
|
||||||
organizationId: req.organization.id,
|
organizationId: req.organization.id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (existingLists.length !== lists.length) {
|
if (existingLists.length !== lists.length) {
|
||||||
const foundListIds = existingLists.map((list) => list.id)
|
const foundListIds = existingLists.map((list) => list.id);
|
||||||
const missingListId = lists.find((id) => !foundListIds.includes(id))
|
const missingListId = lists.find((id) => !foundListIds.includes(id));
|
||||||
res.status(400).json({ error: `List with id ${missingListId} not found` })
|
res
|
||||||
return
|
.status(400)
|
||||||
|
.json({ error: `List with id ${missingListId} not found` });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSubscriber = await prisma.subscriber.findFirst({
|
const existingSubscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -170,92 +172,92 @@ apiRouter.post("/subscribers", async (req, res) => {
|
|||||||
},
|
},
|
||||||
Metadata: true,
|
Metadata: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const existingListIds =
|
const existingListIds =
|
||||||
existingSubscriber?.ListSubscribers.map((list) => list.List.id) || []
|
existingSubscriber?.ListSubscribers.map((list) => list.List.id) || [];
|
||||||
const allLists = existingListIds.concat(lists)
|
const allLists = existingListIds.concat(lists);
|
||||||
|
|
||||||
const uniqueLists = [...new Set(allLists)]
|
const uniqueLists = [...new Set(allLists)];
|
||||||
|
|
||||||
const isExpired = existingSubscriber?.emailVerificationTokenExpiresAt
|
const isExpired = existingSubscriber?.emailVerificationTokenExpiresAt
|
||||||
? dayjs(existingSubscriber.emailVerificationTokenExpiresAt).isBefore(
|
? dayjs(existingSubscriber.emailVerificationTokenExpiresAt).isBefore(
|
||||||
dayjs()
|
dayjs(),
|
||||||
)
|
)
|
||||||
: true
|
: true;
|
||||||
|
|
||||||
const shouldSendVerificationEmail =
|
const shouldSendVerificationEmail =
|
||||||
doubleOptIn && !existingSubscriber?.emailVerified && isExpired
|
doubleOptIn && !existingSubscriber?.emailVerified && isExpired;
|
||||||
|
|
||||||
if (shouldSendVerificationEmail) {
|
if (shouldSendVerificationEmail) {
|
||||||
const emailVerificationToken = crypto.randomBytes(32).toString("hex")
|
const emailVerificationToken = crypto.randomBytes(32).toString("hex");
|
||||||
const emailVerificationTokenExpiresAt = dayjs().add(24, "hours").toDate()
|
const emailVerificationTokenExpiresAt = dayjs().add(24, "hours").toDate();
|
||||||
const emailVerified = false
|
const emailVerified = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const smtpSettings = await prisma.smtpSettings.findFirst({
|
const smtpSettings = await prisma.smtpSettings.findFirst({
|
||||||
where: { organizationId: req.organization.id },
|
where: { organizationId: req.organization.id },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!smtpSettings) {
|
if (!smtpSettings) {
|
||||||
console.error(
|
console.error(
|
||||||
`SMTP settings not found for organization ${req.organization.id}.`
|
`SMTP settings not found for organization ${req.organization.id}.`,
|
||||||
)
|
);
|
||||||
res.status(422).json({
|
res.status(422).json({
|
||||||
error:
|
error:
|
||||||
"SMTP settings not configured for this organization. Cannot send verification email.",
|
"SMTP settings not configured for this organization. Cannot send verification email.",
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generalSettings = await prisma.generalSettings.findFirst({
|
const generalSettings = await prisma.generalSettings.findFirst({
|
||||||
where: { organizationId: req.organization.id },
|
where: { organizationId: req.organization.id },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!generalSettings || !generalSettings.baseURL) {
|
if (!generalSettings || !generalSettings.baseURL) {
|
||||||
console.error(
|
console.error(
|
||||||
`General settings (especially baseURL) not found for organization ${req.organization.id}.`
|
`General settings (especially baseURL) not found for organization ${req.organization.id}.`,
|
||||||
)
|
);
|
||||||
res.status(422).json({
|
res.status(422).json({
|
||||||
error:
|
error:
|
||||||
"Base URL not configured in general settings for this organization. Cannot send verification email.",
|
"Base URL not configured in general settings for this organization. Cannot send verification email.",
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fromEmailAddress =
|
const fromEmailAddress =
|
||||||
smtpSettings.fromEmail || generalSettings.defaultFromEmail
|
smtpSettings.fromEmail || generalSettings.defaultFromEmail;
|
||||||
if (!fromEmailAddress) {
|
if (!fromEmailAddress) {
|
||||||
console.error(
|
console.error(
|
||||||
`Sender email (fromEmail/defaultFromEmail) not configured for organization ${req.organization.id}.`
|
`Sender email (fromEmail/defaultFromEmail) not configured for organization ${req.organization.id}.`,
|
||||||
)
|
);
|
||||||
res.status(422).json({
|
res.status(422).json({
|
||||||
error:
|
error:
|
||||||
"Sender email not configured for this organization. Cannot send verification email.",
|
"Sender email not configured for this organization. Cannot send verification email.",
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mailer = new Mailer(smtpSettings)
|
const mailer = new Mailer(smtpSettings);
|
||||||
const verificationLink = `${generalSettings.baseURL.replace(/\/$/, "")}/verify-email?token=${emailVerificationToken}`
|
const verificationLink = `${generalSettings.baseURL.replace(/\/$/, "")}/verify-email?token=${emailVerificationToken}`;
|
||||||
|
|
||||||
const templatePath = path.join(
|
const templatePath = path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
"../../templates/verificationEmail.html"
|
"../../templates/verificationEmail.html",
|
||||||
)
|
);
|
||||||
let emailHtmlContent = await fs.readFile(templatePath, "utf-8")
|
let emailHtmlContent = await fs.readFile(templatePath, "utf-8");
|
||||||
|
|
||||||
emailHtmlContent = emailHtmlContent
|
emailHtmlContent = emailHtmlContent
|
||||||
.replace(/{{name}}/g, name || "there")
|
.replace(/{{name}}/g, name || "there")
|
||||||
.replace(/{{verificationLink}}/g, verificationLink)
|
.replace(/{{verificationLink}}/g, verificationLink)
|
||||||
.replace(/{{currentYear}}/g, new Date().getFullYear().toString())
|
.replace(/{{currentYear}}/g, new Date().getFullYear().toString());
|
||||||
|
|
||||||
await mailer.sendEmail({
|
await mailer.sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
from: fromEmailAddress,
|
from: fromEmailAddress,
|
||||||
subject: "Verify Your Email Address",
|
subject: "Verify Your Email Address",
|
||||||
html: emailHtmlContent,
|
html: emailHtmlContent,
|
||||||
})
|
});
|
||||||
|
|
||||||
const subscriber = await prisma.subscriber.upsert({
|
const subscriber = await prisma.subscriber.upsert({
|
||||||
where: { id: existingSubscriber?.id || "create" },
|
where: { id: existingSubscriber?.id || "create" },
|
||||||
@@ -310,7 +312,7 @@ apiRouter.post("/subscribers", async (req, res) => {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
id: subscriber.id,
|
id: subscriber.id,
|
||||||
@@ -323,29 +325,29 @@ apiRouter.post("/subscribers", async (req, res) => {
|
|||||||
})),
|
})),
|
||||||
metadata: subscriber.Metadata.reduce(
|
metadata: subscriber.Metadata.reduce(
|
||||||
(acc, meta) => {
|
(acc, meta) => {
|
||||||
acc[meta.key] = meta.value
|
acc[meta.key] = meta.value;
|
||||||
return acc
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, string>
|
{} as Record<string, string>,
|
||||||
),
|
),
|
||||||
emailVerified: subscriber.emailVerified,
|
emailVerified: subscriber.emailVerified,
|
||||||
createdAt: subscriber.createdAt,
|
createdAt: subscriber.createdAt,
|
||||||
updatedAt: subscriber.updatedAt,
|
updatedAt: subscriber.updatedAt,
|
||||||
}
|
};
|
||||||
|
|
||||||
console.log("data", data)
|
console.log("data", data);
|
||||||
|
|
||||||
res.status(201).json(data)
|
res.status(201).json(data);
|
||||||
return
|
return;
|
||||||
} catch (emailError: any) {
|
} catch (emailError: any) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error sending verification email to ${email}:`,
|
`Error sending verification email to ${email}:`,
|
||||||
emailError
|
emailError,
|
||||||
)
|
);
|
||||||
res.status(422).json({
|
res.status(422).json({
|
||||||
error: `Failed to send verification email: ${emailError.message || "Unknown reason"}`,
|
error: `Failed to send verification email: ${emailError.message || "Unknown reason"}`,
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +406,7 @@ apiRouter.post("/subscribers", async (req, res) => {
|
|||||||
},
|
},
|
||||||
Metadata: true,
|
Metadata: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
id: subscriber.id,
|
id: subscriber.id,
|
||||||
@@ -417,22 +419,22 @@ apiRouter.post("/subscribers", async (req, res) => {
|
|||||||
})),
|
})),
|
||||||
metadata: subscriber.Metadata.reduce(
|
metadata: subscriber.Metadata.reduce(
|
||||||
(acc, meta) => {
|
(acc, meta) => {
|
||||||
acc[meta.key] = meta.value
|
acc[meta.key] = meta.value;
|
||||||
return acc
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, string>
|
{} as Record<string, string>,
|
||||||
),
|
),
|
||||||
emailVerified: subscriber.emailVerified,
|
emailVerified: subscriber.emailVerified,
|
||||||
createdAt: subscriber.createdAt,
|
createdAt: subscriber.createdAt,
|
||||||
updatedAt: subscriber.updatedAt,
|
updatedAt: subscriber.updatedAt,
|
||||||
}
|
};
|
||||||
|
|
||||||
res.status(201).json(data)
|
res.status(201).json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating subscriber", error)
|
console.error("Error creating subscriber", error);
|
||||||
res.status(500).json({ error: "Server error" })
|
res.status(500).json({ error: "Server error" });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -498,22 +500,22 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
|
|||||||
metadata: z.record(z.string(), z.string()).optional(),
|
metadata: z.record(z.string(), z.string()).optional(),
|
||||||
emailVerified: z.boolean().optional(),
|
emailVerified: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.safeParse(req.body)
|
.safeParse(req.body);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
res
|
res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: error.issues[0]?.message || "Invalid input data" })
|
.json({ error: error.issues[0]?.message || "Invalid input data" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, name, lists, metadata: newMetadata, emailVerified } = body
|
const { email, name, lists, metadata: newMetadata, emailVerified } = body;
|
||||||
|
|
||||||
const id = req.params.id
|
const id = req.params.id;
|
||||||
|
|
||||||
if (typeof id !== "string") {
|
if (typeof id !== "string") {
|
||||||
res.status(400).json({ error: "Invalid id" })
|
res.status(400).json({ error: "Invalid id" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.subscriber.findFirst({
|
const subscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -529,10 +531,10 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
|
|||||||
},
|
},
|
||||||
Metadata: true,
|
Metadata: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
res.status(404).json({ error: "Subscriber not found" })
|
res.status(404).json({ error: "Subscriber not found" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lists?.length) {
|
if (lists?.length) {
|
||||||
@@ -543,15 +545,15 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
|
|||||||
},
|
},
|
||||||
organizationId: req.organization.id,
|
organizationId: req.organization.id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (existingLists.length !== lists.length) {
|
if (existingLists.length !== lists.length) {
|
||||||
const foundListIds = existingLists.map((list) => list.id)
|
const foundListIds = existingLists.map((list) => list.id);
|
||||||
const missingListId = lists.find((id) => !foundListIds.includes(id))
|
const missingListId = lists.find((id) => !foundListIds.includes(id));
|
||||||
res
|
res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: `List with id ${missingListId} not found` })
|
.json({ error: `List with id ${missingListId} not found` });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +593,7 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
|
|||||||
},
|
},
|
||||||
Metadata: true,
|
Metadata: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
id: updatedSubscriber.id,
|
id: updatedSubscriber.id,
|
||||||
@@ -604,21 +606,21 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
|
|||||||
})),
|
})),
|
||||||
metadata: updatedSubscriber.Metadata.reduce(
|
metadata: updatedSubscriber.Metadata.reduce(
|
||||||
(acc, meta) => {
|
(acc, meta) => {
|
||||||
acc[meta.key] = meta.value
|
acc[meta.key] = meta.value;
|
||||||
return acc
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, string>
|
{} as Record<string, string>,
|
||||||
),
|
),
|
||||||
createdAt: updatedSubscriber.createdAt,
|
createdAt: updatedSubscriber.createdAt,
|
||||||
updatedAt: updatedSubscriber.updatedAt,
|
updatedAt: updatedSubscriber.updatedAt,
|
||||||
}
|
};
|
||||||
|
|
||||||
res.json(data)
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating subscriber", error)
|
console.error("Error updating subscriber", error);
|
||||||
res.status(500).json({ error: "Server error" })
|
res.status(500).json({ error: "Server error" });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -655,11 +657,11 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
apiRouter.delete("/subscribers/:id", async (req, res) => {
|
apiRouter.delete("/subscribers/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params
|
const { id } = req.params;
|
||||||
|
|
||||||
if (typeof id !== "string") {
|
if (typeof id !== "string") {
|
||||||
res.status(400).json({ error: "Invalid id" })
|
res.status(400).json({ error: "Invalid id" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.subscriber.findFirst({
|
const subscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -667,25 +669,25 @@ apiRouter.delete("/subscribers/:id", async (req, res) => {
|
|||||||
id,
|
id,
|
||||||
organizationId: req.organization.id,
|
organizationId: req.organization.id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
res.status(404).json({ error: "Subscriber not found" })
|
res.status(404).json({ error: "Subscriber not found" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.subscriber.delete({
|
await prisma.subscriber.delete({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
res.json({ success: true })
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting subscriber", error)
|
console.error("Error deleting subscriber", error);
|
||||||
res.status(500).json({ error: "Server error" })
|
res.status(500).json({ error: "Server error" });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -719,11 +721,11 @@ apiRouter.delete("/subscribers/:id", async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
apiRouter.get("/subscribers/:id", async (req, res) => {
|
apiRouter.get("/subscribers/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params
|
const { id } = req.params;
|
||||||
|
|
||||||
if (typeof id !== "string") {
|
if (typeof id !== "string") {
|
||||||
res.status(400).json({ error: "Invalid id" })
|
res.status(400).json({ error: "Invalid id" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.subscriber.findFirst({
|
const subscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -740,10 +742,10 @@ apiRouter.get("/subscribers/:id", async (req, res) => {
|
|||||||
Metadata: true,
|
Metadata: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
})
|
});
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
res.status(404).json({ error: "Subscriber not found" })
|
res.status(404).json({ error: "Subscriber not found" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriberData = {
|
const subscriberData = {
|
||||||
@@ -757,21 +759,21 @@ apiRouter.get("/subscribers/:id", async (req, res) => {
|
|||||||
})),
|
})),
|
||||||
metadata: subscriber.Metadata.reduce(
|
metadata: subscriber.Metadata.reduce(
|
||||||
(acc, meta) => {
|
(acc, meta) => {
|
||||||
acc[meta.key] = meta.value
|
acc[meta.key] = meta.value;
|
||||||
return acc
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, string>
|
{} as Record<string, string>,
|
||||||
),
|
),
|
||||||
createdAt: subscriber.createdAt,
|
createdAt: subscriber.createdAt,
|
||||||
updatedAt: subscriber.updatedAt,
|
updatedAt: subscriber.updatedAt,
|
||||||
}
|
};
|
||||||
|
|
||||||
res.json(subscriberData)
|
res.json(subscriberData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching subscriber", error)
|
console.error("Error fetching subscriber", error);
|
||||||
res.status(500).json({ error: "Server error" })
|
res.status(500).json({ error: "Server error" });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -852,30 +854,30 @@ apiRouter.get("/subscribers", async (req, res) => {
|
|||||||
emailEquals: z.string().optional(),
|
emailEquals: z.string().optional(),
|
||||||
nameEquals: z.string().optional(),
|
nameEquals: z.string().optional(),
|
||||||
})
|
})
|
||||||
.safeParse(req.query)
|
.safeParse(req.query);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
res
|
res
|
||||||
.status(400)
|
.status(400)
|
||||||
.json({ error: error.issues[0]?.message || "Invalid input data" })
|
.json({ error: error.issues[0]?.message || "Invalid input data" });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, perPage, emailEquals, nameEquals } = query
|
const { page, perPage, emailEquals, nameEquals } = query;
|
||||||
|
|
||||||
const where: Prisma.SubscriberWhereInput = {
|
const where: Prisma.SubscriberWhereInput = {
|
||||||
organizationId: req.organization.id,
|
organizationId: req.organization.id,
|
||||||
}
|
};
|
||||||
|
|
||||||
if (emailEquals) {
|
if (emailEquals) {
|
||||||
where.email = { equals: emailEquals }
|
where.email = { equals: emailEquals };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nameEquals) {
|
if (nameEquals) {
|
||||||
where.name = { equals: nameEquals }
|
where.name = { equals: nameEquals };
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = await prisma.subscriber.count({ where })
|
const total = await prisma.subscriber.count({ where });
|
||||||
const subscribers = await prisma.subscriber.findMany({
|
const subscribers = await prisma.subscriber.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
@@ -889,9 +891,9 @@ apiRouter.get("/subscribers", async (req, res) => {
|
|||||||
},
|
},
|
||||||
Metadata: true,
|
Metadata: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / perPage)
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
|
||||||
const subscribersFormatted = subscribers.map((subscriber) => ({
|
const subscribersFormatted = subscribers.map((subscriber) => ({
|
||||||
id: subscriber.id,
|
id: subscriber.id,
|
||||||
@@ -904,14 +906,14 @@ apiRouter.get("/subscribers", async (req, res) => {
|
|||||||
})),
|
})),
|
||||||
metadata: subscriber.Metadata.reduce(
|
metadata: subscriber.Metadata.reduce(
|
||||||
(acc, meta) => {
|
(acc, meta) => {
|
||||||
acc[meta.key] = meta.value
|
acc[meta.key] = meta.value;
|
||||||
return acc
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, string>
|
{} as Record<string, string>,
|
||||||
),
|
),
|
||||||
createdAt: subscriber.createdAt,
|
createdAt: subscriber.createdAt,
|
||||||
updatedAt: subscriber.updatedAt,
|
updatedAt: subscriber.updatedAt,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
data: subscribersFormatted,
|
data: subscribersFormatted,
|
||||||
@@ -922,9 +924,9 @@ apiRouter.get("/subscribers", async (req, res) => {
|
|||||||
totalPages,
|
totalPages,
|
||||||
hasMore: page < totalPages,
|
hasMore: page < totalPages,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching subscribers", error)
|
console.error("Error fetching subscribers", error);
|
||||||
res.status(500).json({ error: "Server error" })
|
res.status(500).json({ error: "Server error" });
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import * as trpcExpress from "@trpc/server/adapters/express"
|
import * as trpcExpress from "@trpc/server/adapters/express";
|
||||||
import path from "path"
|
import path from "path";
|
||||||
import express from "express"
|
import express from "express";
|
||||||
import cors from "cors"
|
import cors from "cors";
|
||||||
import { prisma } from "./utils/prisma"
|
import { prisma } from "./utils/prisma";
|
||||||
import swaggerUi from "swagger-ui-express"
|
import swaggerUi from "swagger-ui-express";
|
||||||
|
|
||||||
import { createContext, router } from "./trpc"
|
import { createContext, router } from "./trpc";
|
||||||
import { userRouter } from "./user/router"
|
import { userRouter } from "./user/router";
|
||||||
import { listRouter } from "./list/router"
|
import { listRouter } from "./list/router";
|
||||||
import { organizationRouter } from "./organization/router"
|
import { organizationRouter } from "./organization/router";
|
||||||
import { subscriberRouter } from "./subscriber/router"
|
import { subscriberRouter } from "./subscriber/router";
|
||||||
import { templateRouter } from "./template/router"
|
import { templateRouter } from "./template/router";
|
||||||
import { campaignRouter } from "./campaign/router"
|
import { campaignRouter } from "./campaign/router";
|
||||||
import { messageRouter } from "./message/router"
|
import { messageRouter } from "./message/router";
|
||||||
import { settingsRouter } from "./settings/router"
|
import { settingsRouter } from "./settings/router";
|
||||||
import swaggerSpec from "./swagger"
|
import swaggerSpec from "./swagger";
|
||||||
import { apiRouter } from "./api/server"
|
import { apiRouter } from "./api/server";
|
||||||
import { dashboardRouter } from "./dashboard/router"
|
import { dashboardRouter } from "./dashboard/router";
|
||||||
import { statsRouter } from "./stats/router"
|
import { statsRouter } from "./stats/router";
|
||||||
import { ONE_PX_PNG } from "./constants"
|
import { ONE_PX_PNG } from "./constants";
|
||||||
|
|
||||||
const appRouter = router({
|
const appRouter = router({
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
@@ -31,36 +31,36 @@ const appRouter = router({
|
|||||||
settings: settingsRouter,
|
settings: settingsRouter,
|
||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
stats: statsRouter,
|
stats: statsRouter,
|
||||||
})
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
||||||
export const app = express()
|
export const app = express();
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: ["http://localhost:3000", "http://localhost:4173"],
|
origin: ["http://localhost:3000", "http://localhost:4173"],
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
app.use(express.json())
|
app.use(express.json());
|
||||||
|
|
||||||
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec))
|
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||||
|
|
||||||
app.get("/t/:id", async (req, res) => {
|
app.get("/t/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params
|
const { id } = req.params;
|
||||||
const subscriberId = req.query.sid
|
const subscriberId = req.query.sid;
|
||||||
|
|
||||||
const trackedLink = await prisma.trackedLink.findUnique({
|
const trackedLink = await prisma.trackedLink.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!trackedLink) {
|
if (!trackedLink) {
|
||||||
res.status(404).send("Link not found")
|
res.status(404).send("Link not found");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.redirect(trackedLink.url)
|
res.redirect(trackedLink.url);
|
||||||
|
|
||||||
if (subscriberId && typeof subscriberId === "string") {
|
if (subscriberId && typeof subscriberId === "string") {
|
||||||
await prisma
|
await prisma
|
||||||
@@ -71,9 +71,9 @@ app.get("/t/:id", async (req, res) => {
|
|||||||
subscriberId,
|
subscriberId,
|
||||||
trackedLinkId: trackedLink.id,
|
trackedLinkId: trackedLink.id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!trackedLink.campaignId) return
|
if (!trackedLink.campaignId) return;
|
||||||
|
|
||||||
const message = await tx.message.findFirst({
|
const message = await tx.message.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -83,9 +83,9 @@ app.get("/t/:id", async (req, res) => {
|
|||||||
not: "CLICKED",
|
not: "CLICKED",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!message) return
|
if (!message) return;
|
||||||
|
|
||||||
await tx.message.update({
|
await tx.message.update({
|
||||||
where: {
|
where: {
|
||||||
@@ -94,27 +94,27 @@ app.get("/t/:id", async (req, res) => {
|
|||||||
data: {
|
data: {
|
||||||
status: "CLICKED",
|
status: "CLICKED",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error updating message status", error)
|
console.error("Error updating message status", error);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(404).send("Link not found")
|
res.status(404).send("Link not found");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
app.get("/img/:id/img.png", async (req, res) => {
|
app.get("/img/:id/img.png", async (req, res) => {
|
||||||
// Send pixel immediately
|
// Send pixel immediately
|
||||||
const pixel = Buffer.from(ONE_PX_PNG, "base64")
|
const pixel = Buffer.from(ONE_PX_PNG, "base64");
|
||||||
res.setHeader("Content-Type", "image/png")
|
res.setHeader("Content-Type", "image/png");
|
||||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate")
|
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
res.setHeader("Pragma", "no-cache")
|
res.setHeader("Pragma", "no-cache");
|
||||||
res.setHeader("Expires", "0")
|
res.setHeader("Expires", "0");
|
||||||
res.end(pixel)
|
res.end(pixel);
|
||||||
|
|
||||||
const id = req.params.id
|
const id = req.params.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -125,41 +125,41 @@ app.get("/img/:id/img.png", async (req, res) => {
|
|||||||
openTracking: true,
|
openTracking: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.status !== "SENT") return
|
if (message.status !== "SENT") return;
|
||||||
|
|
||||||
await tx.message.update({
|
await tx.message.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
status: "OPENED",
|
status: "OPENED",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating message status", error)
|
console.error("Error updating message status", error);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
app.use("/api", apiRouter)
|
app.use("/api", apiRouter);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
"/trpc",
|
"/trpc",
|
||||||
trpcExpress.createExpressMiddleware({
|
trpcExpress.createExpressMiddleware({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext,
|
createContext,
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
|
|
||||||
const staticPath = path.join(__dirname, "..", "..", "web", "dist")
|
const staticPath = path.join(__dirname, "..", "..", "web", "dist");
|
||||||
|
|
||||||
// serve SPA content
|
// serve SPA content
|
||||||
app.use(express.static(staticPath))
|
app.use(express.static(staticPath));
|
||||||
|
|
||||||
app.get("*", (_, res) => {
|
app.get("*", (_, res) => {
|
||||||
res.sendFile(path.join(staticPath, "index.html"))
|
res.sendFile(path.join(staticPath, "index.html"));
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import pMap from "p-map"
|
import pMap from "p-map";
|
||||||
import { Mailer } from "../lib/Mailer"
|
import { Mailer } from "../lib/Mailer";
|
||||||
|
|
||||||
const createCampaignSchema = z.object({
|
const createCampaignSchema = z.object({
|
||||||
title: z.string().min(1, "Campaign title is required"),
|
title: z.string().min(1, "Campaign title is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createCampaign = authProcedure
|
export const createCampaign = authProcedure
|
||||||
.input(createCampaignSchema)
|
.input(createCampaignSchema)
|
||||||
@@ -19,13 +19,13 @@ export const createCampaign = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaign = await prisma.campaign.create({
|
const campaign = await prisma.campaign.create({
|
||||||
@@ -43,10 +43,10 @@ export const createCampaign = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { campaign }
|
return { campaign };
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateCampaignSchema = z.object({
|
const updateCampaignSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -59,7 +59,7 @@ const updateCampaignSchema = z.object({
|
|||||||
scheduledAt: z.date().optional().nullable(),
|
scheduledAt: z.date().optional().nullable(),
|
||||||
content: z.string().optional().nullable(),
|
content: z.string().optional().nullable(),
|
||||||
openTracking: z.boolean().optional(),
|
openTracking: z.boolean().optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateCampaign = authProcedure
|
export const updateCampaign = authProcedure
|
||||||
.input(updateCampaignSchema)
|
.input(updateCampaignSchema)
|
||||||
@@ -69,13 +69,13 @@ export const updateCampaign = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaign = await prisma.campaign.findFirst({
|
const campaign = await prisma.campaign.findFirst({
|
||||||
@@ -83,20 +83,20 @@ export const updateCampaign = authProcedure
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Campaign not found",
|
message: "Campaign not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (campaign.status !== "DRAFT") {
|
if (campaign.status !== "DRAFT") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Campaign can not be updated!",
|
message: "Campaign can not be updated!",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a templateId is provided, ensure it exists
|
// If a templateId is provided, ensure it exists
|
||||||
@@ -106,13 +106,13 @@ export const updateCampaign = authProcedure
|
|||||||
id: input.templateId,
|
id: input.templateId,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Template not found",
|
message: "Template not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,13 +122,13 @@ export const updateCampaign = authProcedure
|
|||||||
id: { in: input.listIds },
|
id: { in: input.listIds },
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (lists.length !== input.listIds.length) {
|
if (lists.length !== input.listIds.length) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "One or more lists not found",
|
message: "One or more lists not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,17 +157,17 @@ export const updateCampaign = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { campaign: updatedCampaign }
|
return { campaign: updatedCampaign };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deleteCampaign = authProcedure
|
export const deleteCampaign = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -175,13 +175,13 @@ export const deleteCampaign = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaign = await prisma.campaign.findFirst({
|
const campaign = await prisma.campaign.findFirst({
|
||||||
@@ -189,29 +189,29 @@ export const deleteCampaign = authProcedure
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Campaign not found",
|
message: "Campaign not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Delete: Cascade delete all messages
|
// On Delete: Cascade delete all messages
|
||||||
await prisma.campaign.delete({
|
await prisma.campaign.delete({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
})
|
});
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const startCampaign = authProcedure
|
export const startCampaign = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -219,13 +219,13 @@ export const startCampaign = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [smtpSettings, emailSettings] = await Promise.all([
|
const [smtpSettings, emailSettings] = await Promise.all([
|
||||||
@@ -239,14 +239,14 @@ export const startCampaign = authProcedure
|
|||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
if (!smtpSettings) {
|
if (!smtpSettings) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message:
|
message:
|
||||||
"You must configure your SMTP settings before running a campaign",
|
"You must configure your SMTP settings before running a campaign",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailSettings) {
|
if (!emailSettings) {
|
||||||
@@ -254,7 +254,7 @@ export const startCampaign = authProcedure
|
|||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message:
|
message:
|
||||||
"You must configure your email delivery settings before running a campaign",
|
"You must configure your email delivery settings before running a campaign",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaign = await prisma.campaign.findFirst({
|
const campaign = await prisma.campaign.findFirst({
|
||||||
@@ -288,13 +288,13 @@ export const startCampaign = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Campaign not found",
|
message: "Campaign not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check campaign status
|
// Check campaign status
|
||||||
@@ -302,14 +302,14 @@ export const startCampaign = authProcedure
|
|||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Campaign can only be started from DRAFT status",
|
message: "Campaign can only be started from DRAFT status",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!campaign.subject) {
|
if (!campaign.subject) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Email Subject is required",
|
message: "Email Subject is required",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check campaign has lists
|
// Check campaign has lists
|
||||||
@@ -317,7 +317,7 @@ export const startCampaign = authProcedure
|
|||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Campaign must have at least one list",
|
message: "Campaign must have at least one list",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!campaign.content) {
|
if (!campaign.content) {
|
||||||
@@ -325,75 +325,78 @@ export const startCampaign = authProcedure
|
|||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message:
|
message:
|
||||||
"Can not send an empty campaign. Write some content in the editor to start sending.",
|
"Can not send an empty campaign. Write some content in the editor to start sending.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subscriber =
|
type Subscriber =
|
||||||
(typeof campaign)["CampaignLists"][0]["List"]["ListSubscribers"][0]["Subscriber"] & {
|
(typeof campaign)["CampaignLists"][0]["List"]["ListSubscribers"][0]["Subscriber"] & {
|
||||||
Metadata: { key: string; value: string }[]
|
Metadata: { key: string; value: string }[];
|
||||||
}
|
};
|
||||||
|
|
||||||
const subscribers = new Map<string, Subscriber>()
|
const subscribers = new Map<string, Subscriber>();
|
||||||
await pMap(campaign.CampaignLists, (campaignList) => {
|
await pMap(campaign.CampaignLists, (campaignList) => {
|
||||||
return pMap(campaignList.List.ListSubscribers, (listSubscriber) => {
|
return pMap(campaignList.List.ListSubscribers, (listSubscriber) => {
|
||||||
subscribers.set(listSubscriber.Subscriber.id, listSubscriber.Subscriber)
|
subscribers.set(
|
||||||
})
|
listSubscriber.Subscriber.id,
|
||||||
})
|
listSubscriber.Subscriber,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (subscribers.size === 0) {
|
if (subscribers.size === 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Campaign must have at least one recipient",
|
message: "Campaign must have at least one recipient",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const organization = await prisma.organization.findUnique({
|
const organization = await prisma.organization.findUnique({
|
||||||
where: { id: input.organizationId },
|
where: { id: input.organizationId },
|
||||||
select: { name: true },
|
select: { name: true },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Organization details could not be retrieved.",
|
message: "Organization details could not be retrieved.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const generalSettings = await prisma.generalSettings.findFirst({
|
const generalSettings = await prisma.generalSettings.findFirst({
|
||||||
where: {
|
where: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!generalSettings?.baseURL) {
|
if (!generalSettings?.baseURL) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message:
|
message:
|
||||||
"Base URL must be configured in settings before running a campaign",
|
"Base URL must be configured in settings before running a campaign",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
campaign.scheduledAt && campaign.scheduledAt > new Date()
|
campaign.scheduledAt && campaign.scheduledAt > new Date()
|
||||||
? "SCHEDULED"
|
? "SCHEDULED"
|
||||||
: "CREATING"
|
: "CREATING";
|
||||||
|
|
||||||
const updatedCampaign = await prisma.campaign.update({
|
const updatedCampaign = await prisma.campaign.update({
|
||||||
where: { id: campaign.id },
|
where: { id: campaign.id },
|
||||||
data: {
|
data: {
|
||||||
status,
|
status,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { campaign: updatedCampaign }
|
return { campaign: updatedCampaign };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const cancelCampaign = authProcedure
|
export const cancelCampaign = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const campaign = await prisma.campaign.findFirst({
|
const campaign = await prisma.campaign.findFirst({
|
||||||
@@ -401,20 +404,20 @@ export const cancelCampaign = authProcedure
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Campaign not found",
|
message: "Campaign not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!["CREATING", "SENDING", "SCHEDULED"].includes(campaign.status)) {
|
if (!["CREATING", "SENDING", "SCHEDULED"].includes(campaign.status)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Campaign cannot be cancelled",
|
message: "Campaign cannot be cancelled",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
@@ -437,10 +440,10 @@ export const cancelCampaign = authProcedure
|
|||||||
status: "CANCELLED",
|
status: "CANCELLED",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const sendTestEmail = authProcedure
|
export const sendTestEmail = authProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -448,7 +451,7 @@ export const sendTestEmail = authProcedure
|
|||||||
campaignId: z.string(),
|
campaignId: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -456,27 +459,27 @@ export const sendTestEmail = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.smtpSettings.findFirst({
|
const settings = await prisma.smtpSettings.findFirst({
|
||||||
where: {
|
where: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message:
|
message:
|
||||||
"You must configure your SMTP settings before sending test emails",
|
"You must configure your SMTP settings before sending test emails",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaign = await prisma.campaign.findFirst({
|
const campaign = await prisma.campaign.findFirst({
|
||||||
@@ -487,56 +490,56 @@ export const sendTestEmail = authProcedure
|
|||||||
include: {
|
include: {
|
||||||
Template: true,
|
Template: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Campaign not found",
|
message: "Campaign not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!campaign.content) {
|
if (!campaign.content) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Campaign must have content",
|
message: "Campaign must have content",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!campaign.subject) {
|
if (!campaign.subject) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Email Subject is required",
|
message: "Email Subject is required",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = campaign.Template
|
const content = campaign.Template
|
||||||
? campaign.Template.content.replace(/{{content}}/g, campaign.content)
|
? campaign.Template.content.replace(/{{content}}/g, campaign.content)
|
||||||
: campaign.content
|
: campaign.content;
|
||||||
|
|
||||||
const mailer = new Mailer(settings)
|
const mailer = new Mailer(settings);
|
||||||
|
|
||||||
const result = await mailer.sendEmail({
|
const result = await mailer.sendEmail({
|
||||||
to: input.email,
|
to: input.email,
|
||||||
subject: `[Test] ${campaign.subject}`,
|
subject: `[Test] ${campaign.subject}`,
|
||||||
html: content,
|
html: content,
|
||||||
from: `${settings.fromName} <${settings.fromEmail}>`,
|
from: `${settings.fromName} <${settings.fromEmail}>`,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Failed to send test email",
|
message: "Failed to send test email",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|
||||||
const duplicateCampaignSchema = z.object({
|
const duplicateCampaignSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const duplicateCampaign = authProcedure
|
export const duplicateCampaign = authProcedure
|
||||||
.input(duplicateCampaignSchema)
|
.input(duplicateCampaignSchema)
|
||||||
@@ -546,13 +549,13 @@ export const duplicateCampaign = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalCampaign = await prisma.campaign.findFirst({
|
const originalCampaign = await prisma.campaign.findFirst({
|
||||||
@@ -568,13 +571,13 @@ export const duplicateCampaign = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!originalCampaign) {
|
if (!originalCampaign) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Campaign not found",
|
message: "Campaign not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newCampaign = await prisma.campaign.create({
|
const newCampaign = await prisma.campaign.create({
|
||||||
@@ -601,7 +604,7 @@ export const duplicateCampaign = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { campaign: newCampaign }
|
return { campaign: newCampaign };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { paginationSchema } from "../utils/schemas"
|
import { paginationSchema } from "../utils/schemas";
|
||||||
import { Prisma } from "../../prisma/client"
|
import { Prisma } from "../../prisma/client";
|
||||||
import { resolveProps } from "../utils/pProps"
|
import { resolveProps } from "../utils/pProps";
|
||||||
|
|
||||||
export const listCampaigns = authProcedure
|
export const listCampaigns = authProcedure
|
||||||
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
|
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
|
||||||
@@ -14,13 +14,13 @@ export const listCampaigns = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.CampaignWhereInput = {
|
const where: Prisma.CampaignWhereInput = {
|
||||||
@@ -34,7 +34,7 @@ export const listCampaigns = authProcedure
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const [total, campaigns] = await prisma.$transaction([
|
const [total, campaigns] = await prisma.$transaction([
|
||||||
prisma.campaign.count({ where }),
|
prisma.campaign.count({ where }),
|
||||||
@@ -67,9 +67,9 @@ export const listCampaigns = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / input.perPage)
|
const totalPages = Math.ceil(total / input.perPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
campaigns,
|
campaigns,
|
||||||
@@ -80,15 +80,15 @@ export const listCampaigns = authProcedure
|
|||||||
perPage: input.perPage,
|
perPage: input.perPage,
|
||||||
hasMore: input.page < totalPages,
|
hasMore: input.page < totalPages,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const getCampaign = authProcedure
|
export const getCampaign = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -96,13 +96,13 @@ export const getCampaign = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaign = await prisma.campaign.findFirst({
|
const campaign = await prisma.campaign.findFirst({
|
||||||
@@ -118,13 +118,13 @@ export const getCampaign = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Campaign not found",
|
message: "Campaign not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const listSubscribers = await prisma.listSubscriber.findMany({
|
const listSubscribers = await prisma.listSubscriber.findMany({
|
||||||
@@ -138,7 +138,7 @@ export const getCampaign = authProcedure
|
|||||||
id: true,
|
id: true,
|
||||||
},
|
},
|
||||||
distinct: ["subscriberId"],
|
distinct: ["subscriberId"],
|
||||||
})
|
});
|
||||||
|
|
||||||
// Add the count to each list for backward compatibility
|
// Add the count to each list for backward compatibility
|
||||||
const campaignWithCounts = {
|
const campaignWithCounts = {
|
||||||
@@ -150,7 +150,7 @@ export const getCampaign = authProcedure
|
|||||||
listId: cl.listId,
|
listId: cl.listId,
|
||||||
unsubscribedAt: null,
|
unsubscribedAt: null,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...cl,
|
...cl,
|
||||||
@@ -160,12 +160,12 @@ export const getCampaign = authProcedure
|
|||||||
ListSubscribers: count,
|
ListSubscribers: count,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
}),
|
||||||
),
|
),
|
||||||
// Add the unique subscriber count directly to the campaign object
|
// Add the unique subscriber count directly to the campaign object
|
||||||
uniqueRecipientCount: listSubscribers.length,
|
uniqueRecipientCount: listSubscribers.length,
|
||||||
}
|
};
|
||||||
|
|
||||||
const promises = {
|
const promises = {
|
||||||
totalMessages: prisma.message.count({
|
totalMessages: prisma.message.count({
|
||||||
@@ -221,9 +221,9 @@ export const getCampaign = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const result = await resolveProps(promises)
|
const result = await resolveProps(promises);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
campaign: campaignWithCounts,
|
campaign: campaignWithCounts,
|
||||||
@@ -245,5 +245,5 @@ export const getCampaign = authProcedure
|
|||||||
? (result.opened / result.sentMessages) * 100
|
? (result.opened / result.sentMessages) * 100
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import {
|
import {
|
||||||
createCampaign,
|
createCampaign,
|
||||||
updateCampaign,
|
updateCampaign,
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
cancelCampaign,
|
cancelCampaign,
|
||||||
sendTestEmail,
|
sendTestEmail,
|
||||||
duplicateCampaign,
|
duplicateCampaign,
|
||||||
} from "./mutation"
|
} from "./mutation";
|
||||||
import { getCampaign, listCampaigns } from "./query"
|
import { getCampaign, listCampaigns } from "./query";
|
||||||
|
|
||||||
export const campaignRouter = router({
|
export const campaignRouter = router({
|
||||||
create: createCampaign,
|
create: createCampaign,
|
||||||
@@ -20,4 +20,4 @@ export const campaignRouter = router({
|
|||||||
cancel: cancelCampaign,
|
cancel: cancelCampaign,
|
||||||
sendTestEmail,
|
sendTestEmail,
|
||||||
duplicate: duplicateCampaign,
|
duplicate: duplicateCampaign,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
|
|
||||||
export const env = z
|
export const env = z
|
||||||
.object({
|
.object({
|
||||||
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
|
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
|
||||||
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
|
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
|
||||||
})
|
})
|
||||||
.parse(process.env)
|
.parse(process.env);
|
||||||
|
|
||||||
export const ONE_PX_PNG =
|
export const ONE_PX_PNG =
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||||
|
|||||||
@@ -1,57 +1,57 @@
|
|||||||
import cron from "node-cron"
|
import cron from "node-cron";
|
||||||
import { sendMessagesCron } from "./sendMessages"
|
import { sendMessagesCron } from "./sendMessages";
|
||||||
import { dailyMaintenanceCron } from "./dailyMaintenance"
|
import { dailyMaintenanceCron } from "./dailyMaintenance";
|
||||||
import { processQueuedCampaigns } from "./processQueuedCampaigns"
|
import { processQueuedCampaigns } from "./processQueuedCampaigns";
|
||||||
|
|
||||||
type CronJob = {
|
type CronJob = {
|
||||||
name: string
|
name: string;
|
||||||
schedule: string
|
schedule: string;
|
||||||
job: () => Promise<void>
|
job: () => Promise<void>;
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
const sendMessagesJob: CronJob = {
|
const sendMessagesJob: CronJob = {
|
||||||
name: "send-queued-messages",
|
name: "send-queued-messages",
|
||||||
schedule: "*/5 * * * * *", // Runs every 5 seconds
|
schedule: "*/5 * * * * *", // Runs every 5 seconds
|
||||||
job: sendMessagesCron,
|
job: sendMessagesCron,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
};
|
||||||
|
|
||||||
const dailyMaintenanceJob: CronJob = {
|
const dailyMaintenanceJob: CronJob = {
|
||||||
name: "daily-maintenance",
|
name: "daily-maintenance",
|
||||||
schedule: "0 0 * * *", // Runs daily at midnight
|
schedule: "0 0 * * *", // Runs daily at midnight
|
||||||
job: dailyMaintenanceCron,
|
job: dailyMaintenanceCron,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
};
|
||||||
|
|
||||||
const processQueuedCampaignsJob: CronJob = {
|
const processQueuedCampaignsJob: CronJob = {
|
||||||
name: "process-queued-campaigns",
|
name: "process-queued-campaigns",
|
||||||
schedule: "* * * * * *", // Runs every second
|
schedule: "* * * * * *", // Runs every second
|
||||||
job: processQueuedCampaigns,
|
job: processQueuedCampaigns,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
};
|
||||||
|
|
||||||
const cronJobs: CronJob[] = [
|
const cronJobs: CronJob[] = [
|
||||||
sendMessagesJob,
|
sendMessagesJob,
|
||||||
dailyMaintenanceJob,
|
dailyMaintenanceJob,
|
||||||
processQueuedCampaignsJob,
|
processQueuedCampaignsJob,
|
||||||
]
|
];
|
||||||
|
|
||||||
export const initializeCronJobs = () => {
|
export const initializeCronJobs = () => {
|
||||||
const scheduledJobs = cronJobs
|
const scheduledJobs = cronJobs
|
||||||
.filter((job) => job.enabled)
|
.filter((job) => job.enabled)
|
||||||
.map((job) => {
|
.map((job) => {
|
||||||
const task = cron.schedule(job.schedule, job.job)
|
const task = cron.schedule(job.schedule, job.job);
|
||||||
console.log(
|
console.log(
|
||||||
`Cron job '${job.name}' scheduled with cron expression: ${job.schedule}`
|
`Cron job '${job.name}' scheduled with cron expression: ${job.schedule}`,
|
||||||
)
|
);
|
||||||
return { name: job.name, task }
|
return { name: job.name, task };
|
||||||
})
|
});
|
||||||
|
|
||||||
console.log(`${scheduledJobs.length} cron jobs initialized`)
|
console.log(`${scheduledJobs.length} cron jobs initialized`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobs: scheduledJobs,
|
jobs: scheduledJobs,
|
||||||
stop: () => scheduledJobs.forEach(({ task }) => task.stop()),
|
stop: () => scheduledJobs.forEach(({ task }) => task.stop()),
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const runningJobs = new Map<string, boolean>()
|
const runningJobs = new Map<string, boolean>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper for cron jobs
|
* A wrapper for cron jobs
|
||||||
@@ -6,17 +6,17 @@ const runningJobs = new Map<string, boolean>()
|
|||||||
export function cronJob(name: string, cronFn: () => Promise<void>) {
|
export function cronJob(name: string, cronFn: () => Promise<void>) {
|
||||||
return async () => {
|
return async () => {
|
||||||
if (runningJobs.get(name)) {
|
if (runningJobs.get(name)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
runningJobs.set(name, true)
|
runningJobs.set(name, true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await cronFn()
|
await cronFn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Cron Error:", `[${name}]`, error)
|
console.error("Cron Error:", `[${name}]`, error);
|
||||||
} finally {
|
} finally {
|
||||||
runningJobs.set(name, false)
|
runningJobs.set(name, false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { cronJob } from "./cron.utils"
|
import { cronJob } from "./cron.utils";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
|
export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
|
||||||
const organizations = await prisma.organization.findMany({
|
const organizations = await prisma.organization.findMany({
|
||||||
include: {
|
include: {
|
||||||
GeneralSettings: true,
|
GeneralSettings: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
let totalDeletedMessages = 0
|
let totalDeletedMessages = 0;
|
||||||
|
|
||||||
for (const org of organizations) {
|
for (const org of organizations) {
|
||||||
const cleanupIntervalDays = org.GeneralSettings?.cleanupInterval ?? 30
|
const cleanupIntervalDays = org.GeneralSettings?.cleanupInterval ?? 30;
|
||||||
const cleanupOlderThanDate = dayjs()
|
const cleanupOlderThanDate = dayjs()
|
||||||
.subtract(cleanupIntervalDays, "days")
|
.subtract(cleanupIntervalDays, "days")
|
||||||
.toDate()
|
.toDate();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messagesToClean = await prisma.message.findMany({
|
const messagesToClean = await prisma.message.findMany({
|
||||||
@@ -33,7 +33,7 @@ export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
await prisma.message.updateMany({
|
await prisma.message.updateMany({
|
||||||
data: {
|
data: {
|
||||||
@@ -44,25 +44,25 @@ export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
|
|||||||
in: messagesToClean.map((msg) => msg.id),
|
in: messagesToClean.map((msg) => msg.id),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (messagesToClean.length > 0) {
|
if (messagesToClean.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`Daily maintenance for org ${org.id}: Deleted ${messagesToClean.length} messages older than ${cleanupIntervalDays} days.`
|
`Daily maintenance for org ${org.id}: Deleted ${messagesToClean.length} messages older than ${cleanupIntervalDays} days.`,
|
||||||
)
|
);
|
||||||
totalDeletedMessages += messagesToClean.length
|
totalDeletedMessages += messagesToClean.length;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error deleting messages for org ${org.id}: ${error}`)
|
console.error(`Error deleting messages for org ${org.id}: ${error}`);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalDeletedMessages > 0) {
|
if (totalDeletedMessages > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`Daily maintenance job finished. Total deleted messages: ${totalDeletedMessages}.`
|
`Daily maintenance job finished. Total deleted messages: ${totalDeletedMessages}.`,
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log("Daily maintenance job finished. No messages to delete.")
|
console.log("Daily maintenance job finished. No messages to delete.");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { LinkTracker } from "../lib/LinkTracker"
|
import { LinkTracker } from "../lib/LinkTracker";
|
||||||
import { v4 as uuidV4 } from "uuid"
|
import { v4 as uuidV4 } from "uuid";
|
||||||
import {
|
import {
|
||||||
replacePlaceholders,
|
replacePlaceholders,
|
||||||
PlaceholderDataKey,
|
PlaceholderDataKey,
|
||||||
} from "../utils/placeholder-parser"
|
} from "../utils/placeholder-parser";
|
||||||
import pMap from "p-map"
|
import pMap from "p-map";
|
||||||
import { Subscriber, Prisma, SubscriberMetadata } from "../../prisma/client"
|
import { Subscriber, Prisma, SubscriberMetadata } from "../../prisma/client";
|
||||||
import { cronJob } from "./cron.utils"
|
import { cronJob } from "./cron.utils";
|
||||||
|
|
||||||
// TODO: Make this a config
|
// TODO: Make this a config
|
||||||
const BATCH_SIZE = 100
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
async function getSubscribersForCampaign(
|
async function getSubscribersForCampaign(
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
selectedListIds: string[]
|
selectedListIds: string[],
|
||||||
): Promise<Map<string, Subscriber & { Metadata: SubscriberMetadata[] }>> {
|
): Promise<Map<string, Subscriber & { Metadata: SubscriberMetadata[] }>> {
|
||||||
if (selectedListIds.length === 0) {
|
if (selectedListIds.length === 0) {
|
||||||
return new Map()
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribers = await prisma.subscriber.findMany({
|
const subscribers = await prisma.subscriber.findMany({
|
||||||
@@ -34,19 +34,19 @@ async function getSubscribersForCampaign(
|
|||||||
include: {
|
include: {
|
||||||
Metadata: true,
|
Metadata: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!subscribers.length) return new Map()
|
if (!subscribers.length) return new Map();
|
||||||
|
|
||||||
const subscribersMap = new Map<
|
const subscribersMap = new Map<
|
||||||
string,
|
string,
|
||||||
Subscriber & { Metadata: SubscriberMetadata[] }
|
Subscriber & { Metadata: SubscriberMetadata[] }
|
||||||
>()
|
>();
|
||||||
await pMap(subscribers, async (subscriber) => {
|
await pMap(subscribers, async (subscriber) => {
|
||||||
subscribersMap.set(subscriber.id, subscriber)
|
subscribersMap.set(subscriber.id, subscriber);
|
||||||
})
|
});
|
||||||
|
|
||||||
return subscribersMap
|
return subscribersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logged = {
|
const logged = {
|
||||||
@@ -56,18 +56,18 @@ const logged = {
|
|||||||
missingCampaignContent: false,
|
missingCampaignContent: false,
|
||||||
missingCampaignSubject: false,
|
missingCampaignSubject: false,
|
||||||
errorProcessingCampaign: false,
|
errorProcessingCampaign: false,
|
||||||
}
|
};
|
||||||
|
|
||||||
const oneTimeLogger = (key: keyof typeof logged, ...messages: unknown[]) => {
|
const oneTimeLogger = (key: keyof typeof logged, ...messages: unknown[]) => {
|
||||||
if (!logged[key]) {
|
if (!logged[key]) {
|
||||||
console.log(...messages)
|
console.log(...messages);
|
||||||
logged[key] = true
|
logged[key] = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const turnOnLogger = (key: keyof typeof logged) => {
|
const turnOnLogger = (key: keyof typeof logged) => {
|
||||||
logged[key] = false
|
logged[key] = false;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const processQueuedCampaigns = cronJob(
|
export const processQueuedCampaigns = cronJob(
|
||||||
"process-queued-campaigns",
|
"process-queued-campaigns",
|
||||||
@@ -88,17 +88,17 @@ export const processQueuedCampaigns = cronJob(
|
|||||||
},
|
},
|
||||||
Template: true,
|
Template: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (queuedCampaigns.length === 0) {
|
if (queuedCampaigns.length === 0) {
|
||||||
oneTimeLogger(
|
oneTimeLogger(
|
||||||
"noQueuedCampaigns",
|
"noQueuedCampaigns",
|
||||||
"Cron job: No queued campaigns to process."
|
"Cron job: No queued campaigns to process.",
|
||||||
)
|
);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
turnOnLogger("noQueuedCampaigns")
|
turnOnLogger("noQueuedCampaigns");
|
||||||
|
|
||||||
for (const campaign of queuedCampaigns) {
|
for (const campaign of queuedCampaigns) {
|
||||||
try {
|
try {
|
||||||
@@ -111,82 +111,82 @@ export const processQueuedCampaigns = cronJob(
|
|||||||
) {
|
) {
|
||||||
oneTimeLogger(
|
oneTimeLogger(
|
||||||
"missingCampaignData",
|
"missingCampaignData",
|
||||||
`Cron job: Campaign ${campaign.id} is missing required data (content, subject, organization, or baseURL). Skipping.`
|
`Cron job: Campaign ${campaign.id} is missing required data (content, subject, organization, or baseURL). Skipping.`,
|
||||||
)
|
);
|
||||||
// Optionally, update status to FAILED or similar
|
// Optionally, update status to FAILED or similar
|
||||||
// await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'FAILED', statusReason: 'Missing critical data for processing' } });
|
// await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'FAILED', statusReason: 'Missing critical data for processing' } });
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
turnOnLogger("missingCampaignData")
|
turnOnLogger("missingCampaignData");
|
||||||
|
|
||||||
const generalSettings = campaign.Organization.GeneralSettings
|
const generalSettings = campaign.Organization.GeneralSettings;
|
||||||
|
|
||||||
const selectedListIds = campaign.CampaignLists.map((cl) => cl.listId)
|
const selectedListIds = campaign.CampaignLists.map((cl) => cl.listId);
|
||||||
|
|
||||||
const allSubscribersMap = await getSubscribersForCampaign(
|
const allSubscribersMap = await getSubscribersForCampaign(
|
||||||
campaign.id,
|
campaign.id,
|
||||||
selectedListIds
|
selectedListIds,
|
||||||
)
|
);
|
||||||
if (allSubscribersMap.size === 0) {
|
if (allSubscribersMap.size === 0) {
|
||||||
oneTimeLogger(
|
oneTimeLogger(
|
||||||
"noSubscribers",
|
"noSubscribers",
|
||||||
`Cron job: Campaign ${campaign.id} has no subscribers. Skipping.`
|
`Cron job: Campaign ${campaign.id} has no subscribers. Skipping.`,
|
||||||
)
|
);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
turnOnLogger("noSubscribers")
|
turnOnLogger("noSubscribers");
|
||||||
|
|
||||||
const messageSubscriberIds = (
|
const messageSubscriberIds = (
|
||||||
await prisma.message.findMany({
|
await prisma.message.findMany({
|
||||||
where: { campaignId: campaign.id },
|
where: { campaignId: campaign.id },
|
||||||
select: { subscriberId: true },
|
select: { subscriberId: true },
|
||||||
})
|
})
|
||||||
).map((m) => m.subscriberId)
|
).map((m) => m.subscriberId);
|
||||||
const subscribersWithMessage = new Set(messageSubscriberIds)
|
const subscribersWithMessage = new Set(messageSubscriberIds);
|
||||||
|
|
||||||
const subscribersToProcess = Array.from(
|
const subscribersToProcess = Array.from(
|
||||||
allSubscribersMap.values()
|
allSubscribersMap.values(),
|
||||||
).filter((sub) => !subscribersWithMessage.has(sub.id))
|
).filter((sub) => !subscribersWithMessage.has(sub.id));
|
||||||
|
|
||||||
if (subscribersToProcess.length === 0) {
|
if (subscribersToProcess.length === 0) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const linkTracker = new LinkTracker(tx)
|
const linkTracker = new LinkTracker(tx);
|
||||||
const messagesToCreate: Prisma.MessageCreateManyInput[] = []
|
const messagesToCreate: Prisma.MessageCreateManyInput[] = [];
|
||||||
|
|
||||||
for (const subscriber of subscribersToProcess) {
|
for (const subscriber of subscribersToProcess) {
|
||||||
const messageId = uuidV4()
|
const messageId = uuidV4();
|
||||||
if (!campaign.content) {
|
if (!campaign.content) {
|
||||||
oneTimeLogger(
|
oneTimeLogger(
|
||||||
"missingCampaignContent",
|
"missingCampaignContent",
|
||||||
`Cron job: Campaign ${campaign.id} has no content. Skipping.`
|
`Cron job: Campaign ${campaign.id} has no content. Skipping.`,
|
||||||
)
|
);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
turnOnLogger("missingCampaignContent")
|
turnOnLogger("missingCampaignContent");
|
||||||
|
|
||||||
let emailContent = campaign.Template
|
let emailContent = campaign.Template
|
||||||
? campaign.Template.content.replace(
|
? campaign.Template.content.replace(
|
||||||
/{{content}}/g,
|
/{{content}}/g,
|
||||||
campaign.content
|
campaign.content,
|
||||||
)
|
)
|
||||||
: campaign.content
|
: campaign.content;
|
||||||
|
|
||||||
if (!campaign.subject) {
|
if (!campaign.subject) {
|
||||||
oneTimeLogger(
|
oneTimeLogger(
|
||||||
"missingCampaignSubject",
|
"missingCampaignSubject",
|
||||||
`Cron job: Campaign ${campaign.id} has no subject. Skipping.`
|
`Cron job: Campaign ${campaign.id} has no subject. Skipping.`,
|
||||||
)
|
);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
turnOnLogger("missingCampaignSubject")
|
turnOnLogger("missingCampaignSubject");
|
||||||
|
|
||||||
const placeholderData: Partial<
|
const placeholderData: Partial<
|
||||||
Record<PlaceholderDataKey, string>
|
Record<PlaceholderDataKey, string>
|
||||||
@@ -197,37 +197,37 @@ export const processQueuedCampaigns = cronJob(
|
|||||||
"organization.name": campaign.Organization.name,
|
"organization.name": campaign.Organization.name,
|
||||||
unsubscribe_link: `${generalSettings.baseURL}/unsubscribe?sid=${subscriber.id}&cid=${campaign.id}&mid=${messageId}`,
|
unsubscribe_link: `${generalSettings.baseURL}/unsubscribe?sid=${subscriber.id}&cid=${campaign.id}&mid=${messageId}`,
|
||||||
current_date: new Date().toLocaleDateString("en-CA"),
|
current_date: new Date().toLocaleDateString("en-CA"),
|
||||||
}
|
};
|
||||||
|
|
||||||
if (campaign.openTracking) {
|
if (campaign.openTracking) {
|
||||||
emailContent += `<img src="${generalSettings.baseURL}/img/${messageId}/img.png" alt="" width="1" height="1" style="display:none" />`
|
emailContent += `<img src="${generalSettings.baseURL}/img/${messageId}/img.png" alt="" width="1" height="1" style="display:none" />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subscriber.name) {
|
if (subscriber.name) {
|
||||||
placeholderData["subscriber.name"] = subscriber.name
|
placeholderData["subscriber.name"] = subscriber.name;
|
||||||
}
|
}
|
||||||
if (subscriber.Metadata) {
|
if (subscriber.Metadata) {
|
||||||
for (const meta of subscriber.Metadata) {
|
for (const meta of subscriber.Metadata) {
|
||||||
placeholderData[`subscriber.metadata.${meta.key}`] =
|
placeholderData[`subscriber.metadata.${meta.key}`] =
|
||||||
meta.value
|
meta.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emailContent = replacePlaceholders(emailContent, placeholderData)
|
emailContent = replacePlaceholders(emailContent, placeholderData);
|
||||||
|
|
||||||
if (!generalSettings.baseURL) {
|
if (!generalSettings.baseURL) {
|
||||||
console.error(
|
console.error(
|
||||||
`Cron job: Campaign ${campaign.id} has no baseURL. Skipping.`
|
`Cron job: Campaign ${campaign.id} has no baseURL. Skipping.`,
|
||||||
)
|
);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { content: finalContent } =
|
const { content: finalContent } =
|
||||||
await linkTracker.replaceMessageContentWithTrackedLinks(
|
await linkTracker.replaceMessageContentWithTrackedLinks(
|
||||||
emailContent,
|
emailContent,
|
||||||
campaign.id,
|
campaign.id,
|
||||||
generalSettings.baseURL
|
generalSettings.baseURL,
|
||||||
)
|
);
|
||||||
|
|
||||||
messagesToCreate.push({
|
messagesToCreate.push({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
@@ -235,13 +235,13 @@ export const processQueuedCampaigns = cronJob(
|
|||||||
subscriberId: subscriber.id,
|
subscriberId: subscriber.id,
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
status: "QUEUED",
|
status: "QUEUED",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messagesToCreate.length > 0) {
|
if (messagesToCreate.length > 0) {
|
||||||
await tx.message.createMany({
|
await tx.message.createMany({
|
||||||
data: messagesToCreate,
|
data: messagesToCreate,
|
||||||
})
|
});
|
||||||
|
|
||||||
const subscribersLeft = await tx.subscriber.count({
|
const subscribersLeft = await tx.subscriber.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -253,33 +253,33 @@ export const processQueuedCampaigns = cronJob(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (subscribersLeft === 0) {
|
if (subscribersLeft === 0) {
|
||||||
await tx.campaign.update({
|
await tx.campaign.update({
|
||||||
where: { id: campaign.id },
|
where: { id: campaign.id },
|
||||||
data: { status: "SENDING" },
|
data: { status: "SENDING" },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Cron job: Created ${messagesToCreate.length} messages for campaign ${campaign.id}.`
|
`Cron job: Created ${messagesToCreate.length} messages for campaign ${campaign.id}.`,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 }
|
{ timeout: 60_000 },
|
||||||
) // End transaction
|
); // End transaction
|
||||||
|
|
||||||
turnOnLogger("errorProcessingCampaign")
|
turnOnLogger("errorProcessingCampaign");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
oneTimeLogger(
|
oneTimeLogger(
|
||||||
"errorProcessingCampaign",
|
"errorProcessingCampaign",
|
||||||
`Cron job: Error processing campaign ${campaign.id}:`,
|
`Cron job: Error processing campaign ${campaign.id}:`,
|
||||||
error
|
error,
|
||||||
)
|
);
|
||||||
// Optionally, mark campaign as FAILED
|
// Optionally, mark campaign as FAILED
|
||||||
// await prisma.campaign.update({ where: { id: basicCampaignInfo.id }, data: { status: 'FAILED', statusReason: error.message }});
|
// await prisma.campaign.update({ where: { id: basicCampaignInfo.id }, data: { status: 'FAILED', statusReason: error.message }});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import pMap from "p-map"
|
import pMap from "p-map";
|
||||||
import { Mailer } from "../lib/Mailer"
|
import { Mailer } from "../lib/Mailer";
|
||||||
import { logger } from "../utils/logger"
|
import { logger } from "../utils/logger";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
import { cronJob } from "./cron.utils"
|
import { cronJob } from "./cron.utils";
|
||||||
import { subSeconds } from "date-fns"
|
import { subSeconds } from "date-fns";
|
||||||
|
|
||||||
export const sendMessagesCron = cronJob("sendMessages", async () => {
|
export const sendMessagesCron = cronJob("sendMessages", async () => {
|
||||||
const organizations = await prisma.organization.findMany()
|
const organizations = await prisma.organization.findMany();
|
||||||
|
|
||||||
for (const organization of organizations) {
|
for (const organization of organizations) {
|
||||||
const [smtpSettings, emailSettings, generalSettings] = await Promise.all([
|
const [smtpSettings, emailSettings, generalSettings] = await Promise.all([
|
||||||
@@ -20,16 +20,16 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
|
|||||||
prisma.generalSettings.findFirst({
|
prisma.generalSettings.findFirst({
|
||||||
where: { organizationId: organization.id },
|
where: { organizationId: organization.id },
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
if (!smtpSettings || !emailSettings) {
|
if (!smtpSettings || !emailSettings) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Required settings not found for org ${organization.id}, skipping`
|
`Required settings not found for org ${organization.id}, skipping`,
|
||||||
)
|
);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowStart = subSeconds(new Date(), emailSettings.rateWindow)
|
const windowStart = subSeconds(new Date(), emailSettings.rateWindow);
|
||||||
const sentInWindow = await prisma.message.count({
|
const sentInWindow = await prisma.message.count({
|
||||||
where: {
|
where: {
|
||||||
status: {
|
status: {
|
||||||
@@ -42,12 +42,12 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow)
|
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow);
|
||||||
|
|
||||||
if (availableSlots === 0) {
|
if (availableSlots === 0) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message status is now independent of campaign status.
|
// Message status is now independent of campaign status.
|
||||||
@@ -81,7 +81,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
take: availableSlots,
|
take: availableSlots,
|
||||||
})
|
});
|
||||||
|
|
||||||
const noMoreRetryingMessages = await prisma.message.count({
|
const noMoreRetryingMessages = await prisma.message.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -90,7 +90,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!messages.length && noMoreRetryingMessages === 0) {
|
if (!messages.length && noMoreRetryingMessages === 0) {
|
||||||
await prisma.campaign.updateMany({
|
await prisma.campaign.updateMany({
|
||||||
@@ -109,39 +109,39 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
|
|||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Found ${messages.length} messages to send`)
|
logger.info(`Found ${messages.length} messages to send`);
|
||||||
|
|
||||||
const mailer = new Mailer({
|
const mailer = new Mailer({
|
||||||
...smtpSettings,
|
...smtpSettings,
|
||||||
timeout: emailSettings.connectionTimeout,
|
timeout: emailSettings.connectionTimeout,
|
||||||
})
|
});
|
||||||
|
|
||||||
const fromName =
|
const fromName =
|
||||||
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? ""
|
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? "";
|
||||||
const fromEmail =
|
const fromEmail =
|
||||||
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? ""
|
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? "";
|
||||||
|
|
||||||
if (!fromName || !fromEmail) {
|
if (!fromName || !fromEmail) {
|
||||||
logger.warn("No from name or email found, message will not be sent")
|
logger.warn("No from name or email found, message will not be sent");
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await pMap(
|
await pMap(
|
||||||
messages,
|
messages,
|
||||||
async (message) => {
|
async (message) => {
|
||||||
if (!message.Campaign.subject) {
|
if (!message.Campaign.subject) {
|
||||||
logger.warn("No subject found for campaign")
|
logger.warn("No subject found for campaign");
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.message.update({
|
await prisma.message.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
data: { status: "PENDING" },
|
data: { status: "PENDING" },
|
||||||
})
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await mailer.sendEmail({
|
const result = await mailer.sendEmail({
|
||||||
@@ -149,7 +149,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
|
|||||||
subject: message.Campaign.subject,
|
subject: message.Campaign.subject,
|
||||||
html: message.content,
|
html: message.content,
|
||||||
from: `${fromName} <${fromEmail}>`,
|
from: `${fromName} <${fromEmail}>`,
|
||||||
})
|
});
|
||||||
|
|
||||||
await prisma.message.update({
|
await prisma.message.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
@@ -166,7 +166,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
|
|||||||
},
|
},
|
||||||
lastTriedAt: new Date(),
|
lastTriedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await prisma.message.update({
|
await prisma.message.update({
|
||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
@@ -181,10 +181,10 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
|
|||||||
},
|
},
|
||||||
lastTriedAt: new Date(),
|
lastTriedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ concurrency: emailSettings.concurrency }
|
{ concurrency: emailSettings.concurrency },
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { MessageStatus } from "../../prisma/client"
|
import { MessageStatus } from "../../prisma/client";
|
||||||
import { countDbSize, subscriberGrowthQuery } from "../../prisma/client/sql"
|
import { countDbSize, subscriberGrowthQuery } from "../../prisma/client/sql";
|
||||||
import pMap from "p-map"
|
import pMap from "p-map";
|
||||||
import { subMonths } from "date-fns"
|
import { subMonths } from "date-fns";
|
||||||
|
|
||||||
export const getDashboardStats = authProcedure
|
export const getDashboardStats = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -19,17 +19,17 @@ export const getDashboardStats = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = subMonths(new Date(), 6)
|
const from = subMonths(new Date(), 6);
|
||||||
const to = new Date()
|
const to = new Date();
|
||||||
|
|
||||||
const dateFilter = {
|
const dateFilter = {
|
||||||
...(from && to
|
...(from && to
|
||||||
@@ -40,7 +40,7 @@ export const getDashboardStats = authProcedure
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] =
|
const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -76,20 +76,20 @@ export const getDashboardStats = authProcedure
|
|||||||
|
|
||||||
// Subscriber growth over time
|
// Subscriber growth over time
|
||||||
prisma.$queryRawTyped(
|
prisma.$queryRawTyped(
|
||||||
subscriberGrowthQuery(input.organizationId, from, to)
|
subscriberGrowthQuery(input.organizationId, from, to),
|
||||||
),
|
),
|
||||||
|
|
||||||
prisma.$queryRawTyped(countDbSize(input.organizationId)),
|
prisma.$queryRawTyped(countDbSize(input.organizationId)),
|
||||||
])
|
]);
|
||||||
|
|
||||||
// Process message stats
|
// Process message stats
|
||||||
const messageStatsByStatus = messageStats.reduce(
|
const messageStatsByStatus = messageStats.reduce(
|
||||||
(acc, stat) => {
|
(acc, stat) => {
|
||||||
acc[stat.status as MessageStatus] = stat._count
|
acc[stat.status as MessageStatus] = stat._count;
|
||||||
return acc
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<MessageStatus, number>
|
{} as Record<MessageStatus, number>,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Process recent campaigns
|
// Process recent campaigns
|
||||||
const processedCampaigns = await pMap(recentCampaigns, async (campaign) => {
|
const processedCampaigns = await pMap(recentCampaigns, async (campaign) => {
|
||||||
@@ -105,7 +105,7 @@ export const getDashboardStats = authProcedure
|
|||||||
prisma.message.count({
|
prisma.message.count({
|
||||||
where: { campaignId: campaign.id },
|
where: { campaignId: campaign.id },
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: campaign.id,
|
id: campaign.id,
|
||||||
@@ -116,24 +116,24 @@ export const getDashboardStats = authProcedure
|
|||||||
totalMessages: totalCount,
|
totalMessages: totalCount,
|
||||||
sentMessages: deliveredCount,
|
sentMessages: deliveredCount,
|
||||||
createdAt: campaign.createdAt,
|
createdAt: campaign.createdAt,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
const subscriberGrowthCumulative: { date: Date; count: number }[] = []
|
const subscriberGrowthCumulative: { date: Date; count: number }[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < subscriberGrowth.length; i++) {
|
for (let i = 0; i < subscriberGrowth.length; i++) {
|
||||||
const point = subscriberGrowth[i]
|
const point = subscriberGrowth[i];
|
||||||
|
|
||||||
if (!point?.date) {
|
if (!point?.date) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = subscriberGrowthCumulative[i - 1]?.count || 0
|
const prev = subscriberGrowthCumulative[i - 1]?.count || 0;
|
||||||
|
|
||||||
subscriberGrowthCumulative.push({
|
subscriberGrowthCumulative.push({
|
||||||
date: point.date,
|
date: point.date,
|
||||||
count: Number(point.count) + Number(prev),
|
count: Number(point.count) + Number(prev),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -141,5 +141,5 @@ export const getDashboardStats = authProcedure
|
|||||||
recentCampaigns: processedCampaigns,
|
recentCampaigns: processedCampaigns,
|
||||||
subscriberGrowth: subscriberGrowthCumulative,
|
subscriberGrowth: subscriberGrowthCumulative,
|
||||||
dbSize,
|
dbSize,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import { getDashboardStats } from "./query"
|
import { getDashboardStats } from "./query";
|
||||||
|
|
||||||
export const dashboardRouter = router({
|
export const dashboardRouter = router({
|
||||||
getStats: getDashboardStats,
|
getStats: getDashboardStats,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
export type * from "./app"
|
export type * from "./app";
|
||||||
export type * from "../prisma/client"
|
export type * from "../prisma/client";
|
||||||
export type * from "./types"
|
export type * from "./types";
|
||||||
|
|
||||||
import { app } from "./app"
|
import { app } from "./app";
|
||||||
import { initializeCronJobs } from "./cron/cron"
|
import { initializeCronJobs } from "./cron/cron";
|
||||||
import { prisma } from "./utils/prisma"
|
import { prisma } from "./utils/prisma";
|
||||||
|
|
||||||
const cronController = initializeCronJobs()
|
const cronController = initializeCronJobs();
|
||||||
|
|
||||||
const PORT = process.env.PORT || 5000
|
const PORT = process.env.PORT || 5000;
|
||||||
|
|
||||||
prisma.$connect().then(async () => {
|
prisma.$connect().then(async () => {
|
||||||
console.log("Connected to database")
|
console.log("Connected to database");
|
||||||
|
|
||||||
// For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED"
|
// For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED"
|
||||||
await prisma.message.updateMany({
|
await prisma.message.updateMany({
|
||||||
@@ -26,19 +26,19 @@ prisma.$connect().then(async () => {
|
|||||||
data: {
|
data: {
|
||||||
status: "CANCELLED",
|
status: "CANCELLED",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server is running on port ${PORT}`)
|
console.log(`Server is running on port ${PORT}`);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// Handle graceful shutdown
|
// Handle graceful shutdown
|
||||||
const shutdown = () => {
|
const shutdown = () => {
|
||||||
console.log("Shutting down cron jobs...")
|
console.log("Shutting down cron jobs...");
|
||||||
cronController.stop()
|
cronController.stop();
|
||||||
process.exit(0)
|
process.exit(0);
|
||||||
}
|
};
|
||||||
|
|
||||||
process.on("SIGINT", shutdown)
|
process.on("SIGINT", shutdown);
|
||||||
process.on("SIGTERM", shutdown)
|
process.on("SIGTERM", shutdown);
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
type TransactionClient = Parameters<
|
type TransactionClient = Parameters<
|
||||||
Parameters<typeof prisma.$transaction>[0]
|
Parameters<typeof prisma.$transaction>[0]
|
||||||
>[0]
|
>[0];
|
||||||
|
|
||||||
export class LinkTracker {
|
export class LinkTracker {
|
||||||
private readonly trackSuffix = "@TRACK"
|
private readonly trackSuffix = "@TRACK";
|
||||||
private readonly tx: TransactionClient
|
private readonly tx: TransactionClient;
|
||||||
|
|
||||||
constructor(tx: TransactionClient) {
|
constructor(tx: TransactionClient) {
|
||||||
this.tx = tx
|
this.tx = tx;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrCreateTrackLink(url: string, campaignId: string) {
|
private async getOrCreateTrackLink(url: string, campaignId: string) {
|
||||||
const originalUrl = url.replace(this.trackSuffix, "")
|
const originalUrl = url.replace(this.trackSuffix, "");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trackedLink = await this.tx.trackedLink.upsert({
|
const trackedLink = await this.tx.trackedLink.upsert({
|
||||||
@@ -28,9 +28,9 @@ export class LinkTracker {
|
|||||||
campaignId,
|
campaignId,
|
||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
})
|
});
|
||||||
|
|
||||||
return trackedLink
|
return trackedLink;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// In case of race condition, try to fetch the existing record
|
// In case of race condition, try to fetch the existing record
|
||||||
return await this.tx.trackedLink.findFirstOrThrow({
|
return await this.tx.trackedLink.findFirstOrThrow({
|
||||||
@@ -38,65 +38,65 @@ export class LinkTracker {
|
|||||||
url: originalUrl,
|
url: originalUrl,
|
||||||
campaignId,
|
campaignId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private findTrackingLinks(content: string) {
|
private findTrackingLinks(content: string) {
|
||||||
const regex = /https?:\/\/[^\s<>"']+@TRACK/g
|
const regex = /https?:\/\/[^\s<>"']+@TRACK/g;
|
||||||
const matches = content.match(regex)
|
const matches = content.match(regex);
|
||||||
|
|
||||||
if (!matches) {
|
if (!matches) {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return matches
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findTrackingLinksAndCreate({
|
async findTrackingLinksAndCreate({
|
||||||
content,
|
content,
|
||||||
campaignId,
|
campaignId,
|
||||||
}: {
|
}: {
|
||||||
content: string
|
content: string;
|
||||||
campaignId: string
|
campaignId: string;
|
||||||
}) {
|
}) {
|
||||||
const links = this.findTrackingLinks(content)
|
const links = this.findTrackingLinks(content);
|
||||||
|
|
||||||
const trackingLinks = await Promise.all(
|
const trackingLinks = await Promise.all(
|
||||||
links.map((link) => this.getOrCreateTrackLink(link, campaignId))
|
links.map((link) => this.getOrCreateTrackLink(link, campaignId)),
|
||||||
)
|
);
|
||||||
|
|
||||||
return trackingLinks
|
return trackingLinks;
|
||||||
}
|
}
|
||||||
|
|
||||||
async replaceMessageContentWithTrackedLinks(
|
async replaceMessageContentWithTrackedLinks(
|
||||||
content: string,
|
content: string,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
baseURL: string
|
baseURL: string,
|
||||||
) {
|
) {
|
||||||
const links = this.findTrackingLinks(content)
|
const links = this.findTrackingLinks(content);
|
||||||
let updatedContent = content
|
let updatedContent = content;
|
||||||
|
|
||||||
const trackedLinkResults = await Promise.all(
|
const trackedLinkResults = await Promise.all(
|
||||||
links.map(async (link) => {
|
links.map(async (link) => {
|
||||||
const trackedLink = await this.getOrCreateTrackLink(link, campaignId)
|
const trackedLink = await this.getOrCreateTrackLink(link, campaignId);
|
||||||
const trackingUrl = `${baseURL}/r/${trackedLink.id}`
|
const trackingUrl = `${baseURL}/r/${trackedLink.id}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originalLink: link,
|
originalLink: link,
|
||||||
trackedLinkId: trackedLink.id,
|
trackedLinkId: trackedLink.id,
|
||||||
trackingUrl,
|
trackingUrl,
|
||||||
}
|
};
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
|
|
||||||
trackedLinkResults.forEach(({ originalLink, trackingUrl }) => {
|
trackedLinkResults.forEach(({ originalLink, trackingUrl }) => {
|
||||||
updatedContent = updatedContent.replace(originalLink, trackingUrl)
|
updatedContent = updatedContent.replace(originalLink, trackingUrl);
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: updatedContent,
|
content: updatedContent,
|
||||||
trackedIds: trackedLinkResults.map(({ trackedLinkId }) => trackedLinkId),
|
trackedIds: trackedLinkResults.map(({ trackedLinkId }) => trackedLinkId),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
import SMTPTransport from "nodemailer/lib/smtp-transport"
|
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||||
import { SmtpSettings } from "../../prisma/client"
|
import { SmtpSettings } from "../../prisma/client";
|
||||||
import nodemailer from "nodemailer"
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
type SendMailOptions = {
|
type SendMailOptions = {
|
||||||
from: string
|
from: string;
|
||||||
to: string
|
to: string;
|
||||||
subject: string
|
subject: string;
|
||||||
html?: string | null
|
html?: string | null;
|
||||||
text?: string | null
|
text?: string | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface Envelope {
|
interface Envelope {
|
||||||
from: string
|
from: string;
|
||||||
to: string[]
|
to: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SMTPResponse {
|
interface SMTPResponse {
|
||||||
accepted: string[]
|
accepted: string[];
|
||||||
rejected: string[]
|
rejected: string[];
|
||||||
ehlo: string[]
|
ehlo: string[];
|
||||||
envelopeTime: number
|
envelopeTime: number;
|
||||||
messageTime: number
|
messageTime: number;
|
||||||
messageSize: number
|
messageSize: number;
|
||||||
response: string
|
response: string;
|
||||||
envelope: Envelope
|
envelope: Envelope;
|
||||||
messageId: string
|
messageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendEmailResponse {
|
interface SendEmailResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
from: string
|
from: string;
|
||||||
messageId?: string
|
messageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransportOptions = SMTPTransport | SMTPTransport.Options | string
|
type TransportOptions = SMTPTransport | SMTPTransport.Options | string;
|
||||||
|
|
||||||
export class Mailer {
|
export class Mailer {
|
||||||
private transporter: nodemailer.Transporter
|
private transporter: nodemailer.Transporter;
|
||||||
|
|
||||||
constructor(smtpSettings: SmtpSettings) {
|
constructor(smtpSettings: SmtpSettings) {
|
||||||
let transportOptions: TransportOptions = {
|
let transportOptions: TransportOptions = {
|
||||||
@@ -47,7 +47,7 @@ export class Mailer {
|
|||||||
user: smtpSettings.username,
|
user: smtpSettings.username,
|
||||||
pass: smtpSettings.password,
|
pass: smtpSettings.password,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
if (smtpSettings.encryption === "STARTTLS") {
|
if (smtpSettings.encryption === "STARTTLS") {
|
||||||
transportOptions = {
|
transportOptions = {
|
||||||
@@ -55,13 +55,13 @@ export class Mailer {
|
|||||||
port: smtpSettings.port || 587, // Default STARTTLS port
|
port: smtpSettings.port || 587, // Default STARTTLS port
|
||||||
secure: false, // Use STARTTLS
|
secure: false, // Use STARTTLS
|
||||||
requireTLS: true, // Require STARTTLS upgrade
|
requireTLS: true, // Require STARTTLS upgrade
|
||||||
}
|
};
|
||||||
} else if (smtpSettings.encryption === "SSL_TLS") {
|
} else if (smtpSettings.encryption === "SSL_TLS") {
|
||||||
transportOptions = {
|
transportOptions = {
|
||||||
...transportOptions,
|
...transportOptions,
|
||||||
port: smtpSettings.port || 465, // Default SSL/TLS port
|
port: smtpSettings.port || 465, // Default SSL/TLS port
|
||||||
secure: true, // Use direct TLS connection
|
secure: true, // Use direct TLS connection
|
||||||
}
|
};
|
||||||
} else {
|
} else {
|
||||||
// NONE encryption
|
// NONE encryption
|
||||||
transportOptions = {
|
transportOptions = {
|
||||||
@@ -70,10 +70,10 @@ export class Mailer {
|
|||||||
secure: false,
|
secure: false,
|
||||||
requireTLS: false, // Explicitly disable TLS requirement
|
requireTLS: false, // Explicitly disable TLS requirement
|
||||||
ignoreTLS: true, // Optionally ignore TLS advertised by server if needed
|
ignoreTLS: true, // Optionally ignore TLS advertised by server if needed
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.transporter = nodemailer.createTransport(transportOptions)
|
this.transporter = nodemailer.createTransport(transportOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendEmail(options: SendMailOptions): Promise<SendEmailResponse> {
|
async sendEmail(options: SendMailOptions): Promise<SendEmailResponse> {
|
||||||
@@ -84,20 +84,20 @@ export class Mailer {
|
|||||||
// TODO: Handle plain text
|
// TODO: Handle plain text
|
||||||
text: options.text || undefined,
|
text: options.text || undefined,
|
||||||
html: options.html || undefined,
|
html: options.html || undefined,
|
||||||
})
|
});
|
||||||
|
|
||||||
let response: SendEmailResponse = {
|
let response: SendEmailResponse = {
|
||||||
success: false,
|
success: false,
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
from: options.from,
|
from: options.from,
|
||||||
}
|
};
|
||||||
|
|
||||||
if (result.accepted.length > 0) {
|
if (result.accepted.length > 0) {
|
||||||
response.success = true
|
response.success = true;
|
||||||
} else if (result.rejected.length > 0) {
|
} else if (result.rejected.length > 0) {
|
||||||
response.success = false
|
response.success = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
const createListSchema = z.object({
|
const createListSchema = z.object({
|
||||||
name: z.string().min(1, "List name is required"),
|
name: z.string().min(1, "List name is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createList = authProcedure
|
export const createList = authProcedure
|
||||||
.input(createListSchema)
|
.input(createListSchema)
|
||||||
@@ -17,13 +17,13 @@ export const createList = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = await prisma.list.create({
|
const list = await prisma.list.create({
|
||||||
@@ -38,18 +38,18 @@ export const createList = authProcedure
|
|||||||
description: true,
|
description: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list,
|
list,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateListSchema = z.object({
|
const updateListSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().min(1, "List name is required"),
|
name: z.string().min(1, "List name is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateList = authProcedure
|
export const updateList = authProcedure
|
||||||
.input(updateListSchema)
|
.input(updateListSchema)
|
||||||
@@ -61,13 +61,13 @@ export const updateList = authProcedure
|
|||||||
include: {
|
include: {
|
||||||
Organization: true,
|
Organization: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!list) {
|
if (!list) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "List not found",
|
message: "List not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to organization
|
// Verify user has access to organization
|
||||||
@@ -76,13 +76,13 @@ export const updateList = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: list.organizationId,
|
organizationId: list.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You don't have access to this list",
|
message: "You don't have access to this list",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedList = await prisma.list.update({
|
const updatedList = await prisma.list.update({
|
||||||
@@ -100,12 +100,12 @@ export const updateList = authProcedure
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: updatedList,
|
list: updatedList,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deleteList = authProcedure
|
export const deleteList = authProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
@@ -117,13 +117,13 @@ export const deleteList = authProcedure
|
|||||||
include: {
|
include: {
|
||||||
Organization: true,
|
Organization: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!list) {
|
if (!list) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "List not found",
|
message: "List not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to organization
|
// Verify user has access to organization
|
||||||
@@ -132,20 +132,20 @@ export const deleteList = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: list.organizationId,
|
organizationId: list.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You don't have access to this list",
|
message: "You don't have access to this list",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.list.delete({
|
await prisma.list.delete({
|
||||||
where: {
|
where: {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { paginationSchema } from "../utils/schemas"
|
import { paginationSchema } from "../utils/schemas";
|
||||||
import { Prisma } from "../../prisma/client"
|
import { Prisma } from "../../prisma/client";
|
||||||
|
|
||||||
export const getLists = authProcedure
|
export const getLists = authProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -11,7 +11,7 @@ export const getLists = authProcedure
|
|||||||
.object({
|
.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
})
|
||||||
.merge(paginationSchema)
|
.merge(paginationSchema),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
// Verify user has access to organization
|
// Verify user has access to organization
|
||||||
@@ -20,13 +20,13 @@ export const getLists = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.ListWhereInput = {
|
const where: Prisma.ListWhereInput = {
|
||||||
@@ -39,7 +39,7 @@ export const getLists = authProcedure
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const [total, lists] = await Promise.all([
|
const [total, lists] = await Promise.all([
|
||||||
prisma.list.count({ where }),
|
prisma.list.count({ where }),
|
||||||
@@ -60,9 +60,9 @@ export const getLists = authProcedure
|
|||||||
skip: (input.page - 1) * input.perPage,
|
skip: (input.page - 1) * input.perPage,
|
||||||
take: input.perPage,
|
take: input.perPage,
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / input.perPage)
|
const totalPages = Math.ceil(total / input.perPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lists,
|
lists,
|
||||||
@@ -73,14 +73,14 @@ export const getLists = authProcedure
|
|||||||
perPage: input.perPage,
|
perPage: input.perPage,
|
||||||
hasMore: input.page < totalPages,
|
hasMore: input.page < totalPages,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const getList = authProcedure
|
export const getList = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const list = await prisma.list.findUnique({
|
const list = await prisma.list.findUnique({
|
||||||
@@ -95,13 +95,13 @@ export const getList = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!list) {
|
if (!list) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "List not found",
|
message: "List not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to organization
|
// Verify user has access to organization
|
||||||
@@ -110,14 +110,14 @@ export const getList = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: list.organizationId,
|
organizationId: list.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You don't have access to this list",
|
message: "You don't have access to this list",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return list
|
return list;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import { createList, updateList, deleteList } from "./mutation"
|
import { createList, updateList, deleteList } from "./mutation";
|
||||||
import { getList, getLists } from "./query"
|
import { getList, getLists } from "./query";
|
||||||
|
|
||||||
export const listRouter = router({
|
export const listRouter = router({
|
||||||
create: createList,
|
create: createList,
|
||||||
@@ -8,4 +8,4 @@ export const listRouter = router({
|
|||||||
delete: deleteList,
|
delete: deleteList,
|
||||||
get: getList,
|
get: getList,
|
||||||
list: getLists,
|
list: getLists,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { MessageStatus } from "../../prisma/client"
|
import { MessageStatus } from "../../prisma/client";
|
||||||
|
|
||||||
export const resendMessage = authProcedure
|
export const resendMessage = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
messageId: z.string(),
|
messageId: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -17,13 +17,13 @@ export const resendMessage = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You do not have access to this organization.",
|
message: "You do not have access to this organization.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await prisma.message.findFirst({
|
const message = await prisma.message.findFirst({
|
||||||
@@ -33,13 +33,13 @@ export const resendMessage = authProcedure
|
|||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Message not found or you don't have access.",
|
message: "Message not found or you don't have access.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedMessage = await prisma.message.update({
|
const updatedMessage = await prisma.message.update({
|
||||||
@@ -53,7 +53,7 @@ export const resendMessage = authProcedure
|
|||||||
error: null,
|
error: null,
|
||||||
messageId: null,
|
messageId: null,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return updatedMessage
|
return updatedMessage;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { paginationSchema } from "../utils/schemas"
|
import { paginationSchema } from "../utils/schemas";
|
||||||
import { Prisma } from "../../prisma/client"
|
import { Prisma } from "../../prisma/client";
|
||||||
|
|
||||||
const messageStatusEnum = z.enum([
|
const messageStatusEnum = z.enum([
|
||||||
"QUEUED",
|
"QUEUED",
|
||||||
@@ -13,7 +13,7 @@ const messageStatusEnum = z.enum([
|
|||||||
"CLICKED",
|
"CLICKED",
|
||||||
"FAILED",
|
"FAILED",
|
||||||
"RETRYING",
|
"RETRYING",
|
||||||
])
|
]);
|
||||||
|
|
||||||
export const listMessages = authProcedure
|
export const listMessages = authProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -24,7 +24,7 @@ export const listMessages = authProcedure
|
|||||||
subscriberId: z.string().optional(),
|
subscriberId: z.string().optional(),
|
||||||
status: messageStatusEnum.optional(),
|
status: messageStatusEnum.optional(),
|
||||||
})
|
})
|
||||||
.merge(paginationSchema)
|
.merge(paginationSchema),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -32,13 +32,13 @@ export const listMessages = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.MessageWhereInput = {
|
const where: Prisma.MessageWhereInput = {
|
||||||
@@ -78,7 +78,7 @@ export const listMessages = authProcedure
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const [total, messages] = await Promise.all([
|
const [total, messages] = await Promise.all([
|
||||||
prisma.message.count({ where }),
|
prisma.message.count({ where }),
|
||||||
@@ -103,9 +103,9 @@ export const listMessages = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / input.perPage)
|
const totalPages = Math.ceil(total / input.perPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
@@ -116,14 +116,14 @@ export const listMessages = authProcedure
|
|||||||
perPage: input.perPage,
|
perPage: input.perPage,
|
||||||
hasMore: input.page < totalPages,
|
hasMore: input.page < totalPages,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const getMessage = authProcedure
|
export const getMessage = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const message = await prisma.message.findUnique({
|
const message = await prisma.message.findUnique({
|
||||||
@@ -146,14 +146,14 @@ export const getMessage = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Message not found",
|
message: "Message not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return message
|
return message;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import { listMessages, getMessage } from "./query"
|
import { listMessages, getMessage } from "./query";
|
||||||
import { resendMessage } from "./mutation"
|
import { resendMessage } from "./mutation";
|
||||||
|
|
||||||
export const messageRouter = router({
|
export const messageRouter = router({
|
||||||
list: listMessages,
|
list: listMessages,
|
||||||
get: getMessage,
|
get: getMessage,
|
||||||
resend: resendMessage,
|
resend: resendMessage,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { MessageStatus } from "../../prisma/client"
|
import { MessageStatus } from "../../prisma/client";
|
||||||
|
|
||||||
interface MessageQueryOptions {
|
interface MessageQueryOptions {
|
||||||
campaignId?: string
|
campaignId?: string;
|
||||||
organizationId: string
|
organizationId: string;
|
||||||
status: MessageStatus | MessageStatus[]
|
status: MessageStatus | MessageStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findMessagesByStatus({
|
export async function findMessagesByStatus({
|
||||||
@@ -20,12 +20,12 @@ export async function findMessagesByStatus({
|
|||||||
},
|
},
|
||||||
status: Array.isArray(status) ? { in: status } : status,
|
status: Array.isArray(status) ? { in: status } : status,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CampaignMessagesQueryOptions {
|
interface CampaignMessagesQueryOptions {
|
||||||
campaignId: string
|
campaignId: string;
|
||||||
organizationId: string
|
organizationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDeliveredMessages({
|
export async function getDeliveredMessages({
|
||||||
@@ -36,7 +36,7 @@ export async function getDeliveredMessages({
|
|||||||
campaignId,
|
campaignId,
|
||||||
organizationId,
|
organizationId,
|
||||||
status: ["SENT", "CLICKED", "OPENED"],
|
status: ["SENT", "CLICKED", "OPENED"],
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFailedMessages({
|
export async function getFailedMessages({
|
||||||
@@ -47,7 +47,7 @@ export async function getFailedMessages({
|
|||||||
campaignId,
|
campaignId,
|
||||||
organizationId,
|
organizationId,
|
||||||
status: "FAILED",
|
status: "FAILED",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOpenedMessages({
|
export async function getOpenedMessages({
|
||||||
@@ -58,7 +58,7 @@ export async function getOpenedMessages({
|
|||||||
campaignId,
|
campaignId,
|
||||||
organizationId,
|
organizationId,
|
||||||
status: ["OPENED", "CLICKED"], // Clicked implies opened
|
status: ["OPENED", "CLICKED"], // Clicked implies opened
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getClickedMessages({
|
export async function getClickedMessages({
|
||||||
@@ -69,7 +69,7 @@ export async function getClickedMessages({
|
|||||||
campaignId,
|
campaignId,
|
||||||
organizationId,
|
organizationId,
|
||||||
status: "CLICKED",
|
status: "CLICKED",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getQueuedMessages({
|
export async function getQueuedMessages({
|
||||||
@@ -80,5 +80,5 @@ export async function getQueuedMessages({
|
|||||||
campaignId,
|
campaignId,
|
||||||
organizationId,
|
organizationId,
|
||||||
status: "QUEUED",
|
status: "QUEUED",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
const createOrganizationSchema = z.object({
|
const createOrganizationSchema = z.object({
|
||||||
name: z.string().min(1, "Organization name is required"),
|
name: z.string().min(1, "Organization name is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createOrganization = authProcedure
|
export const createOrganization = authProcedure
|
||||||
.input(createOrganizationSchema)
|
.input(createOrganizationSchema)
|
||||||
@@ -28,7 +28,7 @@ export const createOrganization = authProcedure
|
|||||||
name: "Newsletter",
|
name: "Newsletter",
|
||||||
content: await fs.readFile(
|
content: await fs.readFile(
|
||||||
"templates/newsletter.html",
|
"templates/newsletter.html",
|
||||||
"utf-8"
|
"utf-8",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -49,18 +49,18 @@ export const createOrganization = authProcedure
|
|||||||
description: true,
|
description: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
organization,
|
organization,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateOrganizationSchema = z.object({
|
const updateOrganizationSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().min(1, "Organization name is required"),
|
name: z.string().min(1, "Organization name is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateOrganization = authProcedure
|
export const updateOrganization = authProcedure
|
||||||
.input(updateOrganizationSchema)
|
.input(updateOrganizationSchema)
|
||||||
@@ -70,13 +70,13 @@ export const updateOrganization = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.id,
|
organizationId: input.id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrg) {
|
if (!userOrg) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You do not have access to update this organization.",
|
message: "You do not have access to update this organization.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedOrganization = await prisma.organization.update({
|
const updatedOrganization = await prisma.organization.update({
|
||||||
@@ -92,7 +92,7 @@ export const updateOrganization = authProcedure
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { organization: updatedOrganization }
|
return { organization: updatedOrganization };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
export const getOrganizationById = authProcedure
|
export const getOrganizationById = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrg = await prisma.userOrganization.findFirst({
|
const userOrg = await prisma.userOrganization.findFirst({
|
||||||
@@ -15,13 +15,13 @@ export const getOrganizationById = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.id,
|
organizationId: input.id,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrg) {
|
if (!userOrg) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You do not have access to this organization.",
|
message: "You do not have access to this organization.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const organization = await prisma.organization.findUnique({
|
const organization = await prisma.organization.findUnique({
|
||||||
@@ -33,14 +33,14 @@ export const getOrganizationById = authProcedure
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Organization not found.",
|
message: "Organization not found.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return organization
|
return organization;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import { createOrganization, updateOrganization } from "./mutation"
|
import { createOrganization, updateOrganization } from "./mutation";
|
||||||
import { getOrganizationById } from "./query"
|
import { getOrganizationById } from "./query";
|
||||||
|
|
||||||
export const organizationRouter = router({
|
export const organizationRouter = router({
|
||||||
create: createOrganization,
|
create: createOrganization,
|
||||||
update: updateOrganization,
|
update: updateOrganization,
|
||||||
getById: getOrganizationById,
|
getById: getOrganizationById,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto";
|
||||||
import { Mailer } from "../lib/Mailer"
|
import { Mailer } from "../lib/Mailer";
|
||||||
|
|
||||||
const smtpSchema = z.object({
|
const smtpSchema = z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
@@ -15,7 +15,7 @@ const smtpSchema = z.object({
|
|||||||
fromName: z.string().optional(),
|
fromName: z.string().optional(),
|
||||||
secure: z.boolean(),
|
secure: z.boolean(),
|
||||||
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
|
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateSmtp = authProcedure
|
export const updateSmtp = authProcedure
|
||||||
.input(smtpSchema)
|
.input(smtpSchema)
|
||||||
@@ -25,13 +25,13 @@ export const updateSmtp = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const smtpSettings = await prisma.smtpSettings.findFirst({
|
const smtpSettings = await prisma.smtpSettings.findFirst({
|
||||||
@@ -45,7 +45,7 @@ export const updateSmtp = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const settings = await prisma.smtpSettings.upsert({
|
const settings = await prisma.smtpSettings.upsert({
|
||||||
where: {
|
where: {
|
||||||
@@ -72,10 +72,10 @@ export const updateSmtp = authProcedure
|
|||||||
secure: input.secure,
|
secure: input.secure,
|
||||||
encryption: input.encryption,
|
encryption: input.encryption,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { settings }
|
return { settings };
|
||||||
})
|
});
|
||||||
|
|
||||||
const emailDeliverySchema = z.object({
|
const emailDeliverySchema = z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
@@ -85,7 +85,7 @@ const emailDeliverySchema = z.object({
|
|||||||
retryDelay: z.number().min(1, "Retry delay is required"),
|
retryDelay: z.number().min(1, "Retry delay is required"),
|
||||||
concurrency: z.number().min(1, "Concurrency must be at least 1"),
|
concurrency: z.number().min(1, "Concurrency must be at least 1"),
|
||||||
connectionTimeout: z.number().min(1, "Connection timeout must be at least 1"),
|
connectionTimeout: z.number().min(1, "Connection timeout must be at least 1"),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateEmailDelivery = authProcedure
|
export const updateEmailDelivery = authProcedure
|
||||||
.input(emailDeliverySchema)
|
.input(emailDeliverySchema)
|
||||||
@@ -95,13 +95,13 @@ export const updateEmailDelivery = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.emailDeliverySettings.upsert({
|
const settings = await prisma.emailDeliverySettings.upsert({
|
||||||
@@ -125,10 +125,10 @@ export const updateEmailDelivery = authProcedure
|
|||||||
concurrency: input.concurrency,
|
concurrency: input.concurrency,
|
||||||
connectionTimeout: input.connectionTimeout,
|
connectionTimeout: input.connectionTimeout,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { settings }
|
return { settings };
|
||||||
})
|
});
|
||||||
|
|
||||||
const generalSettingsSchema = z.object({
|
const generalSettingsSchema = z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
@@ -136,7 +136,7 @@ const generalSettingsSchema = z.object({
|
|||||||
defaultFromName: z.string().optional(),
|
defaultFromName: z.string().optional(),
|
||||||
baseURL: z.string().url().optional().or(z.literal("")),
|
baseURL: z.string().url().optional().or(z.literal("")),
|
||||||
cleanupInterval: z.coerce.number().int().min(1).optional(),
|
cleanupInterval: z.coerce.number().int().min(1).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateGeneral = authProcedure
|
export const updateGeneral = authProcedure
|
||||||
.input(generalSettingsSchema)
|
.input(generalSettingsSchema)
|
||||||
@@ -146,13 +146,13 @@ export const updateGeneral = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.generalSettings.upsert({
|
const settings = await prisma.generalSettings.upsert({
|
||||||
@@ -172,16 +172,16 @@ export const updateGeneral = authProcedure
|
|||||||
baseURL: input.baseURL,
|
baseURL: input.baseURL,
|
||||||
cleanupInterval: input.cleanupInterval,
|
cleanupInterval: input.cleanupInterval,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { settings }
|
return { settings };
|
||||||
})
|
});
|
||||||
|
|
||||||
const createApiKeySchema = z.object({
|
const createApiKeySchema = z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
expiresAt: z.string().optional(),
|
expiresAt: z.string().optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createApiKey = authProcedure
|
export const createApiKey = authProcedure
|
||||||
.input(createApiKeySchema)
|
.input(createApiKeySchema)
|
||||||
@@ -191,19 +191,19 @@ export const createApiKey = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = `sk_${randomBytes(32).toString("hex")}`
|
let key = `sk_${randomBytes(32).toString("hex")}`;
|
||||||
|
|
||||||
while (await prisma.apiKey.findUnique({ where: { key } })) {
|
while (await prisma.apiKey.findUnique({ where: { key } })) {
|
||||||
key = `sk_${randomBytes(32).toString("hex")}`
|
key = `sk_${randomBytes(32).toString("hex")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = await prisma.apiKey.create({
|
const apiKey = await prisma.apiKey.create({
|
||||||
@@ -217,15 +217,15 @@ export const createApiKey = authProcedure
|
|||||||
id: true,
|
id: true,
|
||||||
key: true,
|
key: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return apiKey
|
return apiKey;
|
||||||
})
|
});
|
||||||
|
|
||||||
const deleteApiKeySchema = z.object({
|
const deleteApiKeySchema = z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deleteApiKey = authProcedure
|
export const deleteApiKey = authProcedure
|
||||||
.input(deleteApiKeySchema)
|
.input(deleteApiKeySchema)
|
||||||
@@ -235,13 +235,13 @@ export const deleteApiKey = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.apiKey.delete({
|
await prisma.apiKey.delete({
|
||||||
@@ -249,10 +249,10 @@ export const deleteApiKey = authProcedure
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|
||||||
const createWebhookSchema = z.object({
|
const createWebhookSchema = z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
@@ -261,7 +261,7 @@ const createWebhookSchema = z.object({
|
|||||||
events: z.array(z.string()).min(1, "At least one event must be selected"),
|
events: z.array(z.string()).min(1, "At least one event must be selected"),
|
||||||
isActive: z.boolean(),
|
isActive: z.boolean(),
|
||||||
secret: z.string().min(1, "Secret is required"),
|
secret: z.string().min(1, "Secret is required"),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createWebhook = authProcedure
|
export const createWebhook = authProcedure
|
||||||
.input(createWebhookSchema)
|
.input(createWebhookSchema)
|
||||||
@@ -271,13 +271,13 @@ export const createWebhook = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// const webhook = await prisma.webhook.create({
|
// const webhook = await prisma.webhook.create({
|
||||||
@@ -292,15 +292,15 @@ export const createWebhook = authProcedure
|
|||||||
// })
|
// })
|
||||||
|
|
||||||
// TODO: Implement webhook creation
|
// TODO: Implement webhook creation
|
||||||
return { webhook: null }
|
return { webhook: null };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deleteWebhook = authProcedure
|
export const deleteWebhook = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -308,42 +308,42 @@ export const deleteWebhook = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement webhook deletion
|
// TODO: Implement webhook deletion
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const testSmtp = authProcedure
|
export const testSmtp = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const settings = await prisma.smtpSettings.findFirst({
|
const settings = await prisma.smtpSettings.findFirst({
|
||||||
where: {
|
where: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message:
|
message:
|
||||||
"SMTP settings not found. Please configure your SMTP settings first.",
|
"SMTP settings not found. Please configure your SMTP settings first.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const APP_NAME = "LetterSpace"
|
const APP_NAME = "LetterSpace";
|
||||||
|
|
||||||
const testTemplate = `
|
const testTemplate = `
|
||||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
@@ -357,23 +357,23 @@ export const testSmtp = authProcedure
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`;
|
||||||
|
|
||||||
const mailer = new Mailer(settings)
|
const mailer = new Mailer(settings);
|
||||||
|
|
||||||
const result = await mailer.sendEmail({
|
const result = await mailer.sendEmail({
|
||||||
to: input.email,
|
to: input.email,
|
||||||
subject: "SMTP Configuration Test",
|
subject: "SMTP Configuration Test",
|
||||||
html: testTemplate,
|
html: testTemplate,
|
||||||
from: `${settings.fromName} <${settings.fromEmail}>`,
|
from: `${settings.fromName} <${settings.fromEmail}>`,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Failed to send test email",
|
message: "Failed to send test email",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
export const getSmtp = authProcedure
|
export const getSmtp = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -15,13 +15,13 @@ export const getSmtp = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.smtpSettings.findFirst({
|
const settings = await prisma.smtpSettings.findFirst({
|
||||||
@@ -35,16 +35,16 @@ export const getSmtp = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return settings
|
return settings;
|
||||||
})
|
});
|
||||||
|
|
||||||
export const getGeneral = authProcedure
|
export const getGeneral = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -52,29 +52,29 @@ export const getGeneral = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.generalSettings.findUnique({
|
const settings = await prisma.generalSettings.findUnique({
|
||||||
where: {
|
where: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return settings
|
return settings;
|
||||||
})
|
});
|
||||||
|
|
||||||
export const listApiKeys = authProcedure
|
export const listApiKeys = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -82,13 +82,13 @@ export const listApiKeys = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKeys = await prisma.apiKey.findMany({
|
const apiKeys = await prisma.apiKey.findMany({
|
||||||
@@ -103,16 +103,16 @@ export const listApiKeys = authProcedure
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
})
|
});
|
||||||
|
|
||||||
return apiKeys
|
return apiKeys;
|
||||||
})
|
});
|
||||||
|
|
||||||
export const listWebhooks = authProcedure
|
export const listWebhooks = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -120,17 +120,17 @@ export const listWebhooks = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement later
|
// TODO: Implement later
|
||||||
return []
|
return [];
|
||||||
// const webhooks = await prisma.webhook.findMany({
|
// const webhooks = await prisma.webhook.findMany({
|
||||||
// where: {
|
// where: {
|
||||||
// organizationId: input.organizationId,
|
// organizationId: input.organizationId,
|
||||||
@@ -141,13 +141,13 @@ export const listWebhooks = authProcedure
|
|||||||
// })
|
// })
|
||||||
|
|
||||||
// return webhooks
|
// return webhooks
|
||||||
})
|
});
|
||||||
|
|
||||||
export const getEmailDelivery = authProcedure
|
export const getEmailDelivery = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -155,20 +155,20 @@ export const getEmailDelivery = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await prisma.emailDeliverySettings.findUnique({
|
const settings = await prisma.emailDeliverySettings.findUnique({
|
||||||
where: {
|
where: {
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return settings
|
return settings;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import {
|
import {
|
||||||
getSmtp,
|
getSmtp,
|
||||||
getGeneral,
|
getGeneral,
|
||||||
listApiKeys,
|
listApiKeys,
|
||||||
listWebhooks,
|
listWebhooks,
|
||||||
getEmailDelivery,
|
getEmailDelivery,
|
||||||
} from "./query"
|
} from "./query";
|
||||||
import {
|
import {
|
||||||
updateSmtp,
|
updateSmtp,
|
||||||
testSmtp,
|
testSmtp,
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
createWebhook,
|
createWebhook,
|
||||||
deleteWebhook,
|
deleteWebhook,
|
||||||
updateEmailDelivery,
|
updateEmailDelivery,
|
||||||
} from "./mutation"
|
} from "./mutation";
|
||||||
|
|
||||||
export const settingsRouter = router({
|
export const settingsRouter = router({
|
||||||
getSmtp: getSmtp,
|
getSmtp: getSmtp,
|
||||||
@@ -34,4 +34,4 @@ export const settingsRouter = router({
|
|||||||
listWebhooks: listWebhooks,
|
listWebhooks: listWebhooks,
|
||||||
getEmailDelivery: getEmailDelivery,
|
getEmailDelivery: getEmailDelivery,
|
||||||
updateEmailDelivery: updateEmailDelivery,
|
updateEmailDelivery: updateEmailDelivery,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime"
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
// TODO: move this to a new package named "shared"
|
// TODO: move this to a new package named "shared"
|
||||||
|
|
||||||
export function displayDate(date: Date) {
|
export function displayDate(date: Date) {
|
||||||
const dateObj = dayjs(date)
|
const dateObj = dayjs(date);
|
||||||
|
|
||||||
const daysFromNow = dateObj.diff(dayjs(), "day")
|
const daysFromNow = dateObj.diff(dayjs(), "day");
|
||||||
|
|
||||||
if (daysFromNow > 7) {
|
if (daysFromNow > 7) {
|
||||||
return dateObj.format("DD MMM YYYY")
|
return dateObj.format("DD MMM YYYY");
|
||||||
}
|
}
|
||||||
|
|
||||||
return dateObj.fromNow()
|
return dateObj.fromNow();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { subDays } from "date-fns"
|
import { subDays } from "date-fns";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { resolveProps } from "../utils/pProps"
|
import { resolveProps } from "../utils/pProps";
|
||||||
import {
|
import {
|
||||||
countDistinctRecipients,
|
countDistinctRecipients,
|
||||||
countDistinctRecipientsInTimeRange,
|
countDistinctRecipientsInTimeRange,
|
||||||
} from "../../prisma/client/sql"
|
} from "../../prisma/client/sql";
|
||||||
import { MessageStatus } from "../../prisma/client"
|
import { MessageStatus } from "../../prisma/client";
|
||||||
|
|
||||||
export const getStats = authProcedure
|
export const getStats = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
const thirtyDaysAgo = subDays(now, 30)
|
const thirtyDaysAgo = subDays(now, 30);
|
||||||
const sixtyDaysAgo = subDays(now, 60)
|
const sixtyDaysAgo = subDays(now, 60);
|
||||||
|
|
||||||
const processedMessageStatuses: MessageStatus[] = [
|
const processedMessageStatuses: MessageStatus[] = [
|
||||||
"SENT",
|
"SENT",
|
||||||
"CLICKED",
|
"CLICKED",
|
||||||
"OPENED",
|
"OPENED",
|
||||||
"FAILED",
|
"FAILED",
|
||||||
]
|
];
|
||||||
|
|
||||||
// Check auth
|
// Check auth
|
||||||
const hasAccess = await prisma.userOrganization.findFirst({
|
const hasAccess = await prisma.userOrganization.findFirst({
|
||||||
@@ -34,13 +34,13 @@ export const getStats = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You do not have access to this organization",
|
message: "You do not have access to this organization",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to get this first for calculating the other stats
|
// We need to get this first for calculating the other stats
|
||||||
@@ -78,7 +78,7 @@ export const getStats = authProcedure
|
|||||||
status: { in: processedMessageStatuses },
|
status: { in: processedMessageStatuses },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const promises = {
|
const promises = {
|
||||||
allTimeSubscribers: prisma.subscriber.count({
|
allTimeSubscribers: prisma.subscriber.count({
|
||||||
@@ -109,9 +109,9 @@ export const getStats = authProcedure
|
|||||||
lt: now,
|
lt: now,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return openedMessages / (totalMessagesLast30Days || 1)
|
return openedMessages / (totalMessagesLast30Days || 1);
|
||||||
})(),
|
})(),
|
||||||
openRateLastMonth: (async () => {
|
openRateLastMonth: (async () => {
|
||||||
const openedMessages = await prisma.message.count({
|
const openedMessages = await prisma.message.count({
|
||||||
@@ -127,9 +127,9 @@ export const getStats = authProcedure
|
|||||||
lt: thirtyDaysAgo,
|
lt: thirtyDaysAgo,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return openedMessages / (totalMessagesLastPeriod || 1)
|
return openedMessages / (totalMessagesLastPeriod || 1);
|
||||||
})(),
|
})(),
|
||||||
unsubscribedThisMonth: prisma.listSubscriber.count({
|
unsubscribedThisMonth: prisma.listSubscriber.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -218,12 +218,12 @@ export const getStats = authProcedure
|
|||||||
lt: now,
|
lt: now,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
delivered: deliveredMessages,
|
delivered: deliveredMessages,
|
||||||
rate: deliveredMessages / (totalMessagesLast30Days || 1),
|
rate: deliveredMessages / (totalMessagesLast30Days || 1),
|
||||||
}
|
};
|
||||||
})(),
|
})(),
|
||||||
deliveryRateLastMonth: (async () => {
|
deliveryRateLastMonth: (async () => {
|
||||||
const deliveredMessages = await prisma.message.count({
|
const deliveredMessages = await prisma.message.count({
|
||||||
@@ -239,12 +239,12 @@ export const getStats = authProcedure
|
|||||||
lt: thirtyDaysAgo,
|
lt: thirtyDaysAgo,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
delivered: deliveredMessages,
|
delivered: deliveredMessages,
|
||||||
rate: deliveredMessages / (totalMessagesLastPeriod || 1),
|
rate: deliveredMessages / (totalMessagesLastPeriod || 1),
|
||||||
}
|
};
|
||||||
})(),
|
})(),
|
||||||
clickRateThisMonth: (async () => {
|
clickRateThisMonth: (async () => {
|
||||||
const clickedMessages = await prisma.message.count({
|
const clickedMessages = await prisma.message.count({
|
||||||
@@ -258,12 +258,12 @@ export const getStats = authProcedure
|
|||||||
lt: now,
|
lt: now,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clicked: clickedMessages,
|
clicked: clickedMessages,
|
||||||
rate: clickedMessages / (totalMessagesLast30Days || 1),
|
rate: clickedMessages / (totalMessagesLast30Days || 1),
|
||||||
}
|
};
|
||||||
})(),
|
})(),
|
||||||
clickRateLastMonth: (async () => {
|
clickRateLastMonth: (async () => {
|
||||||
const clickedMessages = await prisma.message.count({
|
const clickedMessages = await prisma.message.count({
|
||||||
@@ -277,33 +277,33 @@ export const getStats = authProcedure
|
|||||||
lt: thirtyDaysAgo,
|
lt: thirtyDaysAgo,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clicked: clickedMessages,
|
clicked: clickedMessages,
|
||||||
rate: clickedMessages / (totalMessagesLastPeriod || 1),
|
rate: clickedMessages / (totalMessagesLastPeriod || 1),
|
||||||
}
|
};
|
||||||
})(),
|
})(),
|
||||||
recipients: prisma.$queryRawTyped(
|
recipients: prisma.$queryRawTyped(
|
||||||
countDistinctRecipients(input.organizationId)
|
countDistinctRecipients(input.organizationId),
|
||||||
),
|
),
|
||||||
recipientsThisMonth: prisma.$queryRawTyped(
|
recipientsThisMonth: prisma.$queryRawTyped(
|
||||||
countDistinctRecipientsInTimeRange(
|
countDistinctRecipientsInTimeRange(
|
||||||
input.organizationId,
|
input.organizationId,
|
||||||
thirtyDaysAgo,
|
thirtyDaysAgo,
|
||||||
now
|
now,
|
||||||
)
|
),
|
||||||
),
|
),
|
||||||
recipientsLastMonth: prisma.$queryRawTyped(
|
recipientsLastMonth: prisma.$queryRawTyped(
|
||||||
countDistinctRecipientsInTimeRange(
|
countDistinctRecipientsInTimeRange(
|
||||||
input.organizationId,
|
input.organizationId,
|
||||||
sixtyDaysAgo,
|
sixtyDaysAgo,
|
||||||
thirtyDaysAgo
|
thirtyDaysAgo,
|
||||||
)
|
|
||||||
),
|
),
|
||||||
}
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const result = await resolveProps(promises)
|
const result = await resolveProps(promises);
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
campaigns: {
|
campaigns: {
|
||||||
@@ -373,7 +373,7 @@ export const getStats = authProcedure
|
|||||||
lastMonth: result.unsubscribedLastMonth,
|
lastMonth: result.unsubscribedLastMonth,
|
||||||
comparison: result.unsubscribedThisMonth - result.unsubscribedLastMonth,
|
comparison: result.unsubscribedThisMonth - result.unsubscribedLastMonth,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
return data
|
return data;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import { getStats } from "./query"
|
import { getStats } from "./query";
|
||||||
|
|
||||||
export const statsRouter = router({
|
export const statsRouter = router({
|
||||||
getStats: getStats,
|
getStats: getStats,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure, publicProcedure } from "../trpc"
|
import { authProcedure, publicProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { parse } from "csv-parse"
|
import { parse } from "csv-parse";
|
||||||
import { Readable } from "stream"
|
import { Readable } from "stream";
|
||||||
|
|
||||||
const createSubscriberSchema = z.object({
|
const createSubscriberSchema = z.object({
|
||||||
email: z.string().email("Invalid email address"),
|
email: z.string().email("Invalid email address"),
|
||||||
@@ -16,10 +16,10 @@ const createSubscriberSchema = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
key: z.string().min(1).max(64),
|
key: z.string().min(1).max(64),
|
||||||
value: z.string().min(1).max(256),
|
value: z.string().min(1).max(256),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createSubscriber = authProcedure
|
export const createSubscriber = authProcedure
|
||||||
.input(createSubscriberSchema)
|
.input(createSubscriberSchema)
|
||||||
@@ -29,13 +29,13 @@ export const createSubscriber = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSubscriber = await prisma.subscriber.findFirst({
|
const existingSubscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -43,13 +43,13 @@ export const createSubscriber = authProcedure
|
|||||||
email: input.email,
|
email: input.email,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (existingSubscriber) {
|
if (existingSubscriber) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
message: "Subscriber with this email already exists",
|
message: "Subscriber with this email already exists",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.subscriber.create({
|
const subscriber = await prisma.subscriber.create({
|
||||||
@@ -76,10 +76,10 @@ export const createSubscriber = authProcedure
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { subscriber }
|
return { subscriber };
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateSubscriberSchema = z.object({
|
const updateSubscriberSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -93,10 +93,10 @@ const updateSubscriberSchema = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
key: z.string().min(1),
|
key: z.string().min(1),
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateSubscriber = authProcedure
|
export const updateSubscriber = authProcedure
|
||||||
.input(updateSubscriberSchema)
|
.input(updateSubscriberSchema)
|
||||||
@@ -106,13 +106,13 @@ export const updateSubscriber = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.subscriber.findFirst({
|
const subscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -123,25 +123,25 @@ export const updateSubscriber = authProcedure
|
|||||||
include: {
|
include: {
|
||||||
ListSubscribers: true,
|
ListSubscribers: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Subscriber not found",
|
message: "Subscriber not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current list IDs
|
// Get current list IDs
|
||||||
const currentListIds = subscriber.ListSubscribers.map((ls) => ls.listId)
|
const currentListIds = subscriber.ListSubscribers.map((ls) => ls.listId);
|
||||||
|
|
||||||
// Find lists to add and remove
|
// Find lists to add and remove
|
||||||
const listsToAdd = input.listIds.filter(
|
const listsToAdd = input.listIds.filter(
|
||||||
(id) => !currentListIds.includes(id)
|
(id) => !currentListIds.includes(id),
|
||||||
)
|
);
|
||||||
const listsToRemove = currentListIds.filter(
|
const listsToRemove = currentListIds.filter(
|
||||||
(id) => !input.listIds.includes(id)
|
(id) => !input.listIds.includes(id),
|
||||||
)
|
);
|
||||||
|
|
||||||
const updatedSubscriber = await prisma.subscriber.update({
|
const updatedSubscriber = await prisma.subscriber.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
@@ -176,17 +176,17 @@ export const updateSubscriber = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { subscriber: updatedSubscriber }
|
return { subscriber: updatedSubscriber };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deleteSubscriber = authProcedure
|
export const deleteSubscriber = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -194,13 +194,13 @@ export const deleteSubscriber = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.subscriber.findFirst({
|
const subscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -208,21 +208,21 @@ export const deleteSubscriber = authProcedure
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Subscriber not found",
|
message: "Subscriber not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.subscriber.delete({
|
await prisma.subscriber.delete({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
})
|
});
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const importSubscribers = authProcedure
|
export const importSubscribers = authProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -230,41 +230,41 @@ export const importSubscribers = authProcedure
|
|||||||
file: z.instanceof(FormData),
|
file: z.instanceof(FormData),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
listId: z.string().optional(),
|
listId: z.string().optional(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const file = input.file.get("file") as File
|
const file = input.file.get("file") as File;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new Error("No file provided")
|
throw new Error("No file provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer())
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const records: any[] = []
|
const records: any[] = [];
|
||||||
|
|
||||||
// Parse CSV
|
// Parse CSV
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const parser = parse({
|
const parser = parse({
|
||||||
columns: true,
|
columns: true,
|
||||||
skip_empty_lines: true,
|
skip_empty_lines: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
parser.on("readable", function () {
|
parser.on("readable", function () {
|
||||||
let record
|
let record;
|
||||||
while ((record = parser.read()) !== null) {
|
while ((record = parser.read()) !== null) {
|
||||||
records.push(record)
|
records.push(record);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
parser.on("error", function (err) {
|
parser.on("error", function (err) {
|
||||||
reject(err)
|
reject(err);
|
||||||
})
|
});
|
||||||
|
|
||||||
parser.on("end", function () {
|
parser.on("end", function () {
|
||||||
resolve(undefined)
|
resolve(undefined);
|
||||||
})
|
});
|
||||||
|
|
||||||
Readable.from(buffer).pipe(parser)
|
Readable.from(buffer).pipe(parser);
|
||||||
})
|
});
|
||||||
|
|
||||||
// Validate and transform records
|
// Validate and transform records
|
||||||
const subscribers = records.map((record) => ({
|
const subscribers = records.map((record) => ({
|
||||||
@@ -283,7 +283,7 @@ export const importSubscribers = authProcedure
|
|||||||
? record.tags.split(",").map((t: string) => t.trim())
|
? record.tags.split(",").map((t: string) => t.trim())
|
||||||
: [],
|
: [],
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// Import subscribers
|
// Import subscribers
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
@@ -298,7 +298,7 @@ export const importSubscribers = authProcedure
|
|||||||
},
|
},
|
||||||
create: sub,
|
create: sub,
|
||||||
update: sub,
|
update: sub,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (input.listId) {
|
if (input.listId) {
|
||||||
await tx.listSubscriber.upsert({
|
await tx.listSubscriber.upsert({
|
||||||
@@ -313,27 +313,27 @@ export const importSubscribers = authProcedure
|
|||||||
subscriberId: subscriber.id,
|
subscriberId: subscriber.id,
|
||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return subscriber
|
return subscriber;
|
||||||
})
|
}),
|
||||||
)
|
);
|
||||||
|
|
||||||
return imported
|
return imported;
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
count: result.length,
|
count: result.length,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const unsubscribeToggle = authProcedure
|
export const unsubscribeToggle = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
listSubscriberId: z.string(),
|
listSubscriberId: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const org = await prisma.userOrganization.findFirst({
|
const org = await prisma.userOrganization.findFirst({
|
||||||
@@ -341,13 +341,13 @@ export const unsubscribeToggle = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const listSubscriber = await prisma.listSubscriber.findFirst({
|
const listSubscriber = await prisma.listSubscriber.findFirst({
|
||||||
@@ -357,13 +357,13 @@ export const unsubscribeToggle = authProcedure
|
|||||||
organizationId: org.organizationId,
|
organizationId: org.organizationId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!listSubscriber) {
|
if (!listSubscriber) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "List subscriber not found",
|
message: "List subscriber not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.listSubscriber.update({
|
const updated = await prisma.listSubscriber.update({
|
||||||
@@ -371,13 +371,13 @@ export const unsubscribeToggle = authProcedure
|
|||||||
data: {
|
data: {
|
||||||
unsubscribedAt: listSubscriber.unsubscribedAt ? null : new Date(),
|
unsubscribedAt: listSubscriber.unsubscribedAt ? null : new Date(),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
subbed: !updated.unsubscribedAt,
|
subbed: !updated.unsubscribedAt,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const publicUnsubscribe = publicProcedure
|
export const publicUnsubscribe = publicProcedure
|
||||||
.input(z.object({ sid: z.string(), cid: z.string() }))
|
.input(z.object({ sid: z.string(), cid: z.string() }))
|
||||||
@@ -395,12 +395,12 @@ export const publicUnsubscribe = publicProcedure
|
|||||||
},
|
},
|
||||||
unsubscribedAt: null,
|
unsubscribedAt: null,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!listSubscribers.length) {
|
if (!listSubscribers.length) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.listSubscriber.updateMany({
|
await prisma.listSubscriber.updateMany({
|
||||||
@@ -412,35 +412,35 @@ export const publicUnsubscribe = publicProcedure
|
|||||||
data: {
|
data: {
|
||||||
unsubscribedAt: new Date(),
|
unsubscribedAt: new Date(),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
await prisma.campaign
|
await prisma.campaign
|
||||||
.update({
|
.update({
|
||||||
where: { id: input.cid },
|
where: { id: input.cid },
|
||||||
data: { unsubscribedCount: { increment: 1 } },
|
data: { unsubscribedCount: { increment: 1 } },
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
}
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TRPCError) {
|
if (error instanceof TRPCError) {
|
||||||
throw error
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Failed to unsubscribe",
|
message: "Failed to unsubscribe",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
export const verifyEmail = publicProcedure
|
export const verifyEmail = publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const subscriber = await prisma.subscriber.findFirst({
|
const subscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -450,13 +450,13 @@ export const verifyEmail = publicProcedure
|
|||||||
gt: new Date(),
|
gt: new Date(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Invalid or expired verification token",
|
message: "Invalid or expired verification token",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.subscriber.update({
|
await prisma.subscriber.update({
|
||||||
@@ -466,7 +466,7 @@ export const verifyEmail = publicProcedure
|
|||||||
emailVerificationToken: null,
|
emailVerificationToken: null,
|
||||||
emailVerificationTokenExpiresAt: null,
|
emailVerificationTokenExpiresAt: null,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { paginationSchema } from "../utils/schemas"
|
import { paginationSchema } from "../utils/schemas";
|
||||||
import { Prisma } from "../../prisma/client"
|
import { Prisma } from "../../prisma/client";
|
||||||
import { resolveProps } from "../utils/pProps"
|
import { resolveProps } from "../utils/pProps";
|
||||||
|
|
||||||
export const listSubscribers = authProcedure
|
export const listSubscribers = authProcedure
|
||||||
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
|
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
|
||||||
@@ -14,13 +14,13 @@ export const listSubscribers = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.SubscriberWhereInput = {
|
const where: Prisma.SubscriberWhereInput = {
|
||||||
@@ -33,7 +33,7 @@ export const listSubscribers = authProcedure
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const promises = {
|
const promises = {
|
||||||
subscribersList: prisma.subscriber.findMany({
|
subscribersList: prisma.subscriber.findMany({
|
||||||
@@ -61,11 +61,11 @@ export const listSubscribers = authProcedure
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
totalItems: prisma.subscriber.count({ where }),
|
totalItems: prisma.subscriber.count({ where }),
|
||||||
}
|
};
|
||||||
|
|
||||||
const result = await resolveProps(promises)
|
const result = await resolveProps(promises);
|
||||||
|
|
||||||
const totalPages = Math.ceil(result.totalItems / input.perPage)
|
const totalPages = Math.ceil(result.totalItems / input.perPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribers: result.subscribersList,
|
subscribers: result.subscribersList,
|
||||||
@@ -76,15 +76,15 @@ export const listSubscribers = authProcedure
|
|||||||
perPage: input.perPage,
|
perPage: input.perPage,
|
||||||
hasMore: input.page < totalPages,
|
hasMore: input.page < totalPages,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const getSubscriber = authProcedure
|
export const getSubscriber = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -92,13 +92,13 @@ export const getSubscriber = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscriber = await prisma.subscriber.findFirst({
|
const subscriber = await prisma.subscriber.findFirst({
|
||||||
@@ -119,14 +119,14 @@ export const getSubscriber = authProcedure
|
|||||||
Metadata: true,
|
Metadata: true,
|
||||||
},
|
},
|
||||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Subscriber not found",
|
message: "Subscriber not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return subscriber
|
return subscriber;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import {
|
import {
|
||||||
createSubscriber,
|
createSubscriber,
|
||||||
updateSubscriber,
|
updateSubscriber,
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
publicUnsubscribe,
|
publicUnsubscribe,
|
||||||
unsubscribeToggle,
|
unsubscribeToggle,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
} from "./mutation"
|
} from "./mutation";
|
||||||
import { getSubscriber, listSubscribers } from "./query"
|
import { getSubscriber, listSubscribers } from "./query";
|
||||||
|
|
||||||
export const subscriberRouter = router({
|
export const subscriberRouter = router({
|
||||||
create: createSubscriber,
|
create: createSubscriber,
|
||||||
@@ -20,4 +20,4 @@ export const subscriberRouter = router({
|
|||||||
unsubscribe: publicUnsubscribe,
|
unsubscribe: publicUnsubscribe,
|
||||||
unsubscribeToggle,
|
unsubscribeToggle,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import swaggerJSDoc from "swagger-jsdoc"
|
import swaggerJSDoc from "swagger-jsdoc";
|
||||||
|
|
||||||
const swaggerDefinition = {
|
const swaggerDefinition = {
|
||||||
openapi: "3.0.0",
|
openapi: "3.0.0",
|
||||||
@@ -6,13 +6,13 @@ const swaggerDefinition = {
|
|||||||
title: "Cat Letter API",
|
title: "Cat Letter API",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
swaggerDefinition,
|
swaggerDefinition,
|
||||||
apis: ["./src/api/server.ts"],
|
apis: ["./src/api/server.ts"],
|
||||||
}
|
};
|
||||||
|
|
||||||
const swaggerSpec = swaggerJSDoc(options)
|
const swaggerSpec = swaggerJSDoc(options);
|
||||||
|
|
||||||
export default swaggerSpec
|
export default swaggerSpec;
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
const contentSchema = z
|
const contentSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "HTML content is required")
|
.min(1, "HTML content is required")
|
||||||
.refine(
|
.refine(
|
||||||
(content) => content.includes("{{content}}"),
|
(content) => content.includes("{{content}}"),
|
||||||
"Content must include the {{content}} placeholder"
|
"Content must include the {{content}} placeholder",
|
||||||
)
|
);
|
||||||
|
|
||||||
const createTemplateSchema = z.object({
|
const createTemplateSchema = z.object({
|
||||||
name: z.string().min(1, "Template name is required"),
|
name: z.string().min(1, "Template name is required"),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
content: contentSchema,
|
content: contentSchema,
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const createTemplate = authProcedure
|
export const createTemplate = authProcedure
|
||||||
.input(createTemplateSchema)
|
.input(createTemplateSchema)
|
||||||
@@ -26,13 +26,13 @@ export const createTemplate = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await prisma.template.create({
|
const template = await prisma.template.create({
|
||||||
@@ -42,16 +42,16 @@ export const createTemplate = authProcedure
|
|||||||
content: input.content,
|
content: input.content,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { template }
|
return { template };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateTemplate = authProcedure
|
export const updateTemplate = authProcedure
|
||||||
.input(
|
.input(
|
||||||
createTemplateSchema.extend({
|
createTemplateSchema.extend({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -59,13 +59,13 @@ export const updateTemplate = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await prisma.template.findFirst({
|
const template = await prisma.template.findFirst({
|
||||||
@@ -73,13 +73,13 @@ export const updateTemplate = authProcedure
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Template not found",
|
message: "Template not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTemplate = await prisma.template.update({
|
const updatedTemplate = await prisma.template.update({
|
||||||
@@ -89,17 +89,17 @@ export const updateTemplate = authProcedure
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
content: input.content,
|
content: input.content,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { template: updatedTemplate }
|
return { template: updatedTemplate };
|
||||||
})
|
});
|
||||||
|
|
||||||
export const deleteTemplate = authProcedure
|
export const deleteTemplate = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -107,13 +107,13 @@ export const deleteTemplate = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await prisma.template.findFirst({
|
const template = await prisma.template.findFirst({
|
||||||
@@ -121,18 +121,18 @@ export const deleteTemplate = authProcedure
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Template not found",
|
message: "Template not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.template.delete({
|
await prisma.template.delete({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
})
|
});
|
||||||
|
|
||||||
return { success: true }
|
return { success: true };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { authProcedure } from "../trpc"
|
import { authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { paginationSchema } from "../utils/schemas"
|
import { paginationSchema } from "../utils/schemas";
|
||||||
import { Prisma } from "../../prisma/client"
|
import { Prisma } from "../../prisma/client";
|
||||||
|
|
||||||
export const listTemplates = authProcedure
|
export const listTemplates = authProcedure
|
||||||
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
|
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
|
||||||
@@ -13,13 +13,13 @@ export const listTemplates = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const where: Prisma.TemplateWhereInput = {
|
const where: Prisma.TemplateWhereInput = {
|
||||||
@@ -32,7 +32,7 @@ export const listTemplates = authProcedure
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}
|
};
|
||||||
|
|
||||||
const [total, templates] = await Promise.all([
|
const [total, templates] = await Promise.all([
|
||||||
prisma.template.count({ where }),
|
prisma.template.count({ where }),
|
||||||
@@ -42,9 +42,9 @@ export const listTemplates = authProcedure
|
|||||||
skip: (input.page - 1) * input.perPage,
|
skip: (input.page - 1) * input.perPage,
|
||||||
take: input.perPage,
|
take: input.perPage,
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / input.perPage)
|
const totalPages = Math.ceil(total / input.perPage);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templates,
|
templates,
|
||||||
@@ -55,15 +55,15 @@ export const listTemplates = authProcedure
|
|||||||
perPage: input.perPage,
|
perPage: input.perPage,
|
||||||
hasMore: input.page < totalPages,
|
hasMore: input.page < totalPages,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const getTemplate = authProcedure
|
export const getTemplate = authProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const userOrganization = await prisma.userOrganization.findFirst({
|
const userOrganization = await prisma.userOrganization.findFirst({
|
||||||
@@ -71,13 +71,13 @@ export const getTemplate = authProcedure
|
|||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!userOrganization) {
|
if (!userOrganization) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Organization not found",
|
message: "Organization not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await prisma.template.findFirst({
|
const template = await prisma.template.findFirst({
|
||||||
@@ -85,14 +85,14 @@ export const getTemplate = authProcedure
|
|||||||
id: input.id,
|
id: input.id,
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
message: "Template not found",
|
message: "Template not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return template
|
return template;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import { createTemplate, updateTemplate, deleteTemplate } from "./mutation"
|
import { createTemplate, updateTemplate, deleteTemplate } from "./mutation";
|
||||||
import { getTemplate, listTemplates } from "./query"
|
import { getTemplate, listTemplates } from "./query";
|
||||||
|
|
||||||
export const templateRouter = router({
|
export const templateRouter = router({
|
||||||
create: createTemplate,
|
create: createTemplate,
|
||||||
@@ -8,4 +8,4 @@ export const templateRouter = router({
|
|||||||
delete: deleteTemplate,
|
delete: deleteTemplate,
|
||||||
get: getTemplate,
|
get: getTemplate,
|
||||||
list: listTemplates,
|
list: listTemplates,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,81 +1,81 @@
|
|||||||
import { initTRPC, TRPCError } from "@trpc/server"
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import * as trpcExpress from "@trpc/server/adapters/express"
|
import * as trpcExpress from "@trpc/server/adapters/express";
|
||||||
import { verifyToken } from "./utils/auth"
|
import { verifyToken } from "./utils/auth";
|
||||||
import { prisma } from "./utils/prisma"
|
import { prisma } from "./utils/prisma";
|
||||||
import { tokenPayloadSchema } from "./utils/token"
|
import { tokenPayloadSchema } from "./utils/token";
|
||||||
import SuperJSON from "superjson"
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Context {
|
interface Context {
|
||||||
user?: User
|
user?: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createContext = async ({
|
export const createContext = async ({
|
||||||
req,
|
req,
|
||||||
}: trpcExpress.CreateExpressContextOptions): Promise<Context> => {
|
}: trpcExpress.CreateExpressContextOptions): Promise<Context> => {
|
||||||
const authHeader = req.headers.authorization
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!authHeader) {
|
if (!authHeader) {
|
||||||
return {}
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = authHeader.split(" ")[1]
|
const token = authHeader.split(" ")[1];
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return {}
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodedRaw = verifyToken(token)
|
const decodedRaw = verifyToken(token);
|
||||||
|
|
||||||
const result = tokenPayloadSchema.safeParse(decodedRaw)
|
const result = tokenPayloadSchema.safeParse(decodedRaw);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return {}
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = result.data
|
const decoded = result.data;
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: decoded.id },
|
where: { id: decoded.id },
|
||||||
select: { id: true, pwdVersion: true },
|
select: { id: true, pwdVersion: true },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {}
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.pwdVersion !== decoded.version) {
|
if (user.pwdVersion !== decoded.version) {
|
||||||
return {}
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user }
|
return { user };
|
||||||
} catch {
|
} catch {
|
||||||
return {}
|
return {};
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const t = initTRPC.context<Context>().create({
|
const t = initTRPC.context<Context>().create({
|
||||||
transformer: SuperJSON,
|
transformer: SuperJSON,
|
||||||
})
|
});
|
||||||
|
|
||||||
export const router = t.router
|
export const router = t.router;
|
||||||
export const publicProcedure = t.procedure
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
export const isAuthedMiddleware = t.middleware(({ ctx, next }) => {
|
export const isAuthedMiddleware = t.middleware(({ ctx, next }) => {
|
||||||
if (!ctx.user) {
|
if (!ctx.user) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You must be logged in to access this resource",
|
message: "You must be logged in to access this resource",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return next({
|
return next({
|
||||||
ctx: {
|
ctx: {
|
||||||
user: ctx.user,
|
user: ctx.user,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
export const authProcedure = t.procedure.use(isAuthedMiddleware)
|
export const authProcedure = t.procedure.use(isAuthedMiddleware);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Organization } from "../prisma/client"
|
import { Organization } from "../prisma/client";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
export namespace Express {
|
export namespace Express {
|
||||||
export interface Request {
|
export interface Request {
|
||||||
organization: Organization
|
organization: Organization;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod";
|
||||||
import { publicProcedure, authProcedure } from "../trpc"
|
import { publicProcedure, authProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
import { comparePasswords, generateToken, hashPassword } from "../utils/auth"
|
import { comparePasswords, generateToken, hashPassword } from "../utils/auth";
|
||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
const signUpSchema = z.object({
|
const signUpSchema = z.object({
|
||||||
email: z.string().email().min(1, "Email is required"),
|
email: z.string().email().min(1, "Email is required"),
|
||||||
password: z.string().min(1, "Password is required"),
|
password: z.string().min(1, "Password is required"),
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const signup = publicProcedure
|
export const signup = publicProcedure
|
||||||
.input(signUpSchema)
|
.input(signUpSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { email, password, name } = input
|
const { email, password, name } = input;
|
||||||
|
|
||||||
if (await prisma.user.count()) {
|
if (await prisma.user.count()) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Bad request",
|
message: "Bad request",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
email,
|
email,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: `User with email ${email} already exists`,
|
message: `User with email ${email} already exists`,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(password)
|
const hashedPassword = await hashPassword(password);
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
@@ -46,24 +46,24 @@ export const signup = publicProcedure
|
|||||||
id: true,
|
id: true,
|
||||||
pwdVersion: true,
|
pwdVersion: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const token = generateToken(user.id, user.pwdVersion)
|
const token = generateToken(user.id, user.pwdVersion);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
export const login = publicProcedure
|
export const login = publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email().min(1, "Email is required"),
|
email: z.string().email().min(1, "Email is required"),
|
||||||
password: z.string().min(1, "Password is required"),
|
password: z.string().min(1, "Password is required"),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { email, password } = input
|
const { email, password } = input;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
@@ -73,45 +73,45 @@ export const login = publicProcedure
|
|||||||
pwdVersion: true,
|
pwdVersion: true,
|
||||||
UserOrganizations: true,
|
UserOrganizations: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Invalid credentials",
|
message: "Invalid credentials",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidPassword = await comparePasswords(password, user.password)
|
const isValidPassword = await comparePasswords(password, user.password);
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Invalid credentials",
|
message: "Invalid credentials",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = generateToken(user.id, user.pwdVersion)
|
const token = generateToken(user.id, user.pwdVersion);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
const updateProfileSchema = z.object({
|
const updateProfileSchema = z.object({
|
||||||
name: z.string().min(1, "Name is required."),
|
name: z.string().min(1, "Name is required."),
|
||||||
email: z.string().email("Invalid email address.").toLowerCase(),
|
email: z.string().email("Invalid email address.").toLowerCase(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const updateProfile = authProcedure
|
export const updateProfile = authProcedure
|
||||||
.input(updateProfileSchema)
|
.input(updateProfileSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { name, email } = input
|
const { name, email } = input;
|
||||||
const userId = ctx.user.id
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
const currentUser = await prisma.user.findUnique({
|
const currentUser = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (currentUser?.email !== email) {
|
if (currentUser?.email !== email) {
|
||||||
const existingUserWithEmail = await prisma.user.findFirst({
|
const existingUserWithEmail = await prisma.user.findFirst({
|
||||||
@@ -119,12 +119,12 @@ export const updateProfile = authProcedure
|
|||||||
email: email,
|
email: email,
|
||||||
id: { not: userId },
|
id: { not: userId },
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
if (existingUserWithEmail) {
|
if (existingUserWithEmail) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Email address is already in use by another account.",
|
message: "Email address is already in use by another account.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,44 +144,44 @@ export const updateProfile = authProcedure
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return { user: updatedUser }
|
return { user: updatedUser };
|
||||||
})
|
});
|
||||||
|
|
||||||
const changePasswordSchema = z.object({
|
const changePasswordSchema = z.object({
|
||||||
currentPassword: z.string(),
|
currentPassword: z.string(),
|
||||||
newPassword: z.string().min(8, "New password must be at least 8 characters."),
|
newPassword: z.string().min(8, "New password must be at least 8 characters."),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const changePassword = authProcedure
|
export const changePassword = authProcedure
|
||||||
.input(changePasswordSchema)
|
.input(changePasswordSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const userId = ctx.user.id
|
const userId = ctx.user.id;
|
||||||
const { currentPassword, newPassword } = input
|
const { currentPassword, newPassword } = input;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { password: true, pwdVersion: true },
|
select: { password: true, pwdVersion: true },
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found." })
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found." });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidPassword = await comparePasswords(
|
const isValidPassword = await comparePasswords(
|
||||||
currentPassword,
|
currentPassword,
|
||||||
user.password
|
user.password,
|
||||||
)
|
);
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Incorrect current password.",
|
message: "Incorrect current password.",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedNewPassword = await hashPassword(newPassword)
|
const hashedNewPassword = await hashPassword(newPassword);
|
||||||
const newPwdVersion = (user.pwdVersion || 0) + 1
|
const newPwdVersion = (user.pwdVersion || 0) + 1;
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -189,9 +189,9 @@ export const changePassword = authProcedure
|
|||||||
password: hashedNewPassword,
|
password: hashedNewPassword,
|
||||||
pwdVersion: newPwdVersion,
|
pwdVersion: newPwdVersion,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const newToken = generateToken(userId, newPwdVersion)
|
const newToken = generateToken(userId, newPwdVersion);
|
||||||
|
|
||||||
return { success: true, token: newToken }
|
return { success: true, token: newToken };
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TRPCError } from "@trpc/server"
|
import { TRPCError } from "@trpc/server";
|
||||||
import { authProcedure, publicProcedure } from "../trpc"
|
import { authProcedure, publicProcedure } from "../trpc";
|
||||||
import { prisma } from "../utils/prisma"
|
import { prisma } from "../utils/prisma";
|
||||||
|
|
||||||
export const me = authProcedure.query(async ({ ctx }) => {
|
export const me = authProcedure.query(async ({ ctx }) => {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -12,19 +12,19 @@ export const me = authProcedure.query(async ({ ctx }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "User not found",
|
message: "User not found",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return user
|
return user;
|
||||||
})
|
});
|
||||||
|
|
||||||
export const isFirstUser = publicProcedure.query(async () => {
|
export const isFirstUser = publicProcedure.query(async () => {
|
||||||
const user = await prisma.user.count()
|
const user = await prisma.user.count();
|
||||||
return user === 0
|
return user === 0;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { router } from "../trpc"
|
import { router } from "../trpc";
|
||||||
import { login, signup, updateProfile, changePassword } from "./mutation"
|
import { login, signup, updateProfile, changePassword } from "./mutation";
|
||||||
import { me, isFirstUser } from "./query"
|
import { me, isFirstUser } from "./query";
|
||||||
|
|
||||||
export const userRouter = router({
|
export const userRouter = router({
|
||||||
signup,
|
signup,
|
||||||
@@ -9,4 +9,4 @@ export const userRouter = router({
|
|||||||
isFirstUser,
|
isFirstUser,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
changePassword,
|
changePassword,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import jwt from "jsonwebtoken"
|
import jwt from "jsonwebtoken";
|
||||||
import bcrypt from "bcryptjs"
|
import bcrypt from "bcryptjs";
|
||||||
import { env } from "../constants"
|
import { env } from "../constants";
|
||||||
|
|
||||||
export async function hashPassword(password: string) {
|
export async function hashPassword(password: string) {
|
||||||
return bcrypt.hash(password, 10)
|
return bcrypt.hash(password, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function comparePasswords(
|
export async function comparePasswords(
|
||||||
password: string,
|
password: string,
|
||||||
hashedPassword: string
|
hashedPassword: string,
|
||||||
) {
|
) {
|
||||||
return bcrypt.compare(password, hashedPassword)
|
return bcrypt.compare(password, hashedPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateToken(userId: string, version: number) {
|
export function generateToken(userId: string, version: number) {
|
||||||
return jwt.sign({ id: userId, version }, env.JWT_SECRET, { expiresIn: "30d" })
|
return jwt.sign({ id: userId, version }, env.JWT_SECRET, {
|
||||||
|
expiresIn: "30d",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyToken(token: string) {
|
export function verifyToken(token: string) {
|
||||||
return jwt.verify(token, env.JWT_SECRET)
|
return jwt.verify(token, env.JWT_SECRET);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
const formatLog = (messages: unknown[]) => {
|
const formatLog = (messages: unknown[]) => {
|
||||||
return `[${new Date().toISOString()}] ${messages.join(" ")}`
|
return `[${new Date().toISOString()}] ${messages.join(" ")}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
log(...messages: unknown[]) {
|
log(...messages: unknown[]) {
|
||||||
console.log(formatLog(messages))
|
console.log(formatLog(messages));
|
||||||
},
|
},
|
||||||
info(...messages: unknown[]) {
|
info(...messages: unknown[]) {
|
||||||
console.log(formatLog(messages))
|
console.log(formatLog(messages));
|
||||||
},
|
},
|
||||||
error(...messages: unknown[]) {
|
error(...messages: unknown[]) {
|
||||||
console.error(formatLog(messages))
|
console.error(formatLog(messages));
|
||||||
},
|
},
|
||||||
warn(...messages: unknown[]) {
|
warn(...messages: unknown[]) {
|
||||||
console.warn(formatLog(messages))
|
console.warn(formatLog(messages));
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
export async function resolveProps<T extends Record<string, Promise<any>>>(
|
export async function resolveProps<T extends Record<string, Promise<any>>>(
|
||||||
promises: T
|
promises: T,
|
||||||
): Promise<{ [K in keyof T]: Awaited<T[K]> }> {
|
): Promise<{ [K in keyof T]: Awaited<T[K]> }> {
|
||||||
const keys = Object.keys(promises)
|
const keys = Object.keys(promises);
|
||||||
const values = await Promise.all(Object.values(promises))
|
const values = await Promise.all(Object.values(promises));
|
||||||
|
|
||||||
return keys.reduce(
|
return keys.reduce(
|
||||||
(acc, key, index) => {
|
(acc, key, index) => {
|
||||||
acc[key as keyof T] = values[index]
|
acc[key as keyof T] = values[index];
|
||||||
return acc
|
return acc;
|
||||||
},
|
},
|
||||||
{} as { [K in keyof T]: Awaited<T[K]> }
|
{} as { [K in keyof T]: Awaited<T[K]> },
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +1,125 @@
|
|||||||
import { replacePlaceholders } from "./placeholder-parser"
|
import { replacePlaceholders } from "./placeholder-parser";
|
||||||
import { describe, it, expect } from "vitest"
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
describe("replacePlaceholders", () => {
|
describe("replacePlaceholders", () => {
|
||||||
it("should replace a single placeholder", () => {
|
it("should replace a single placeholder", () => {
|
||||||
const template = "Hello {{subscriber.name}}!"
|
const template = "Hello {{subscriber.name}}!";
|
||||||
const data = { "subscriber.name": "John" }
|
const data = { "subscriber.name": "John" };
|
||||||
expect(replacePlaceholders(template, data)).toBe("Hello John!")
|
expect(replacePlaceholders(template, data)).toBe("Hello John!");
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should replace multiple placeholders", () => {
|
it("should replace multiple placeholders", () => {
|
||||||
const template = "Order for {{subscriber.name}} from {{organization.name}}."
|
const template =
|
||||||
|
"Order for {{subscriber.name}} from {{organization.name}}.";
|
||||||
const data = {
|
const data = {
|
||||||
"subscriber.name": "Alice",
|
"subscriber.name": "Alice",
|
||||||
"organization.name": "Org Inc",
|
"organization.name": "Org Inc",
|
||||||
}
|
};
|
||||||
expect(replacePlaceholders(template, data)).toBe(
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
"Order for Alice from Org Inc."
|
"Order for Alice from Org Inc.",
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should handle templates with no placeholders", () => {
|
it("should handle templates with no placeholders", () => {
|
||||||
const template = "This is a static string."
|
const template = "This is a static string.";
|
||||||
const data = { "subscriber.name": "Bob" }
|
const data = { "subscriber.name": "Bob" };
|
||||||
expect(replacePlaceholders(template, data)).toBe("This is a static string.")
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
})
|
"This is a static string.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("should handle empty data", () => {
|
it("should handle empty data", () => {
|
||||||
const template = "Hello {{subscriber.name}}!"
|
const template = "Hello {{subscriber.name}}!";
|
||||||
const data = {}
|
const data = {};
|
||||||
expect(replacePlaceholders(template, data)).toBe(
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
"Hello {{subscriber.name}}!"
|
"Hello {{subscriber.name}}!",
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should handle empty template string", () => {
|
it("should handle empty template string", () => {
|
||||||
const template = ""
|
const template = "";
|
||||||
const data = { "subscriber.name": "Eve" }
|
const data = { "subscriber.name": "Eve" };
|
||||||
expect(replacePlaceholders(template, data)).toBe("")
|
expect(replacePlaceholders(template, data)).toBe("");
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should handle placeholders with special characters in keys", () => {
|
it("should handle placeholders with special characters in keys", () => {
|
||||||
const template = "Link: {{unsubscribe_link}}"
|
const template = "Link: {{unsubscribe_link}}";
|
||||||
const data = { unsubscribe_link: "http://example.com/unsubscribe" }
|
const data = { unsubscribe_link: "http://example.com/unsubscribe" };
|
||||||
expect(replacePlaceholders(template, data)).toBe(
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
"Link: http://example.com/unsubscribe"
|
"Link: http://example.com/unsubscribe",
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should replace all occurrences of a placeholder", () => {
|
it("should replace all occurrences of a placeholder", () => {
|
||||||
const template = "Hi {{subscriber.name}}, welcome {{subscriber.name}}."
|
const template = "Hi {{subscriber.name}}, welcome {{subscriber.name}}.";
|
||||||
const data = { "subscriber.name": "Charlie" }
|
const data = { "subscriber.name": "Charlie" };
|
||||||
expect(replacePlaceholders(template, data)).toBe(
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
"Hi Charlie, welcome Charlie."
|
"Hi Charlie, welcome Charlie.",
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should not replace partial matches", () => {
|
it("should not replace partial matches", () => {
|
||||||
const template = "Hello {{subscriber.name}} and {{subscriber.names}}"
|
const template = "Hello {{subscriber.name}} and {{subscriber.names}}";
|
||||||
const data = { "subscriber.name": "David" }
|
const data = { "subscriber.name": "David" };
|
||||||
expect(replacePlaceholders(template, data)).toBe(
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
"Hello David and {{subscriber.names}}"
|
"Hello David and {{subscriber.names}}",
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should correctly replace various types of placeholders", () => {
|
it("should correctly replace various types of placeholders", () => {
|
||||||
const template =
|
const template =
|
||||||
"Email: {{subscriber.email}}, Campaign: {{campaign.name}}, Org: {{organization.name}}, Unsub: {{unsubscribe_link}}, Date: {{current_date}}"
|
"Email: {{subscriber.email}}, Campaign: {{campaign.name}}, Org: {{organization.name}}, Unsub: {{unsubscribe_link}}, Date: {{current_date}}";
|
||||||
const data = {
|
const data = {
|
||||||
"subscriber.email": "test@example.com",
|
"subscriber.email": "test@example.com",
|
||||||
"campaign.name": "Newsletter Q1",
|
"campaign.name": "Newsletter Q1",
|
||||||
"organization.name": "MyCompany",
|
"organization.name": "MyCompany",
|
||||||
unsubscribe_link: "domain.com/unsub",
|
unsubscribe_link: "domain.com/unsub",
|
||||||
current_date: "2024-01-01",
|
current_date: "2024-01-01",
|
||||||
}
|
};
|
||||||
expect(replacePlaceholders(template, data)).toBe(
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
"Email: test@example.com, Campaign: Newsletter Q1, Org: MyCompany, Unsub: domain.com/unsub, Web: domain.com/web, Date: 2024-01-01"
|
"Email: test@example.com, Campaign: Newsletter Q1, Org: MyCompany, Unsub: domain.com/unsub, Web: domain.com/web, Date: 2024-01-01",
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should handle data with undefined values gracefully", () => {
|
it("should handle data with undefined values gracefully", () => {
|
||||||
const template = "Hello {{subscriber.name}} and {{campaign.name}}!"
|
const template = "Hello {{subscriber.name}} and {{campaign.name}}!";
|
||||||
const data = {
|
const data = {
|
||||||
"subscriber.name": "DefinedName",
|
"subscriber.name": "DefinedName",
|
||||||
"campaign.name": undefined,
|
"campaign.name": undefined,
|
||||||
} as { [key: string]: string | undefined } // Added type assertion for clarity
|
} as { [key: string]: string | undefined }; // Added type assertion for clarity
|
||||||
expect(replacePlaceholders(template, data)).toBe(
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
"Hello DefinedName and {{campaign.name}}!"
|
"Hello DefinedName and {{campaign.name}}!",
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should replace placeholders with leading spaces inside braces", () => {
|
it("should replace placeholders with leading spaces inside braces", () => {
|
||||||
const template = "Hello {{ subscriber.name }}!"
|
const template = "Hello {{ subscriber.name }}!";
|
||||||
const data = { "subscriber.name": "SpacedJohn" }
|
const data = { "subscriber.name": "SpacedJohn" };
|
||||||
expect(replacePlaceholders(template, data)).toBe("Hello SpacedJohn!")
|
expect(replacePlaceholders(template, data)).toBe("Hello SpacedJohn!");
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should replace placeholders with trailing spaces inside braces", () => {
|
it("should replace placeholders with trailing spaces inside braces", () => {
|
||||||
const template = "Hello {{subscriber.name }}!"
|
const template = "Hello {{subscriber.name }}!";
|
||||||
const data = { "subscriber.name": "SpacedAlice" }
|
const data = { "subscriber.name": "SpacedAlice" };
|
||||||
expect(replacePlaceholders(template, data)).toBe("Hello SpacedAlice!")
|
expect(replacePlaceholders(template, data)).toBe("Hello SpacedAlice!");
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should replace placeholders with leading and trailing spaces inside braces", () => {
|
it("should replace placeholders with leading and trailing spaces inside braces", () => {
|
||||||
const template = "Hello {{ subscriber.name }}!"
|
const template = "Hello {{ subscriber.name }}!";
|
||||||
const data = { "subscriber.name": "SpacedBob" }
|
const data = { "subscriber.name": "SpacedBob" };
|
||||||
expect(replacePlaceholders(template, data)).toBe("Hello SpacedBob!")
|
expect(replacePlaceholders(template, data)).toBe("Hello SpacedBob!");
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should replace multiple placeholders with various spacing", () => {
|
it("should replace multiple placeholders with various spacing", () => {
|
||||||
const template =
|
const template =
|
||||||
"Hi {{subscriber.name}}, welcome {{ organization.name }}. Date: {{current_date}}."
|
"Hi {{subscriber.name}}, welcome {{ organization.name }}. Date: {{current_date}}.";
|
||||||
const data = {
|
const data = {
|
||||||
"subscriber.name": "SpacedEve",
|
"subscriber.name": "SpacedEve",
|
||||||
"organization.name": "Org Spaced Inc.",
|
"organization.name": "Org Spaced Inc.",
|
||||||
current_date: "2024-02-20",
|
current_date: "2024-02-20",
|
||||||
}
|
};
|
||||||
expect(replacePlaceholders(template, data)).toBe(
|
expect(replacePlaceholders(template, data)).toBe(
|
||||||
"Hi SpacedEve, welcome Org Spaced Inc.. Date: 2024-02-20."
|
"Hi SpacedEve, welcome Org Spaced Inc.. Date: 2024-02-20.",
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
interface SubscriberPlaceholderData {
|
interface SubscriberPlaceholderData {
|
||||||
email: string
|
email: string;
|
||||||
name?: string
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CampaignPlaceholderData {
|
interface CampaignPlaceholderData {
|
||||||
name: string
|
name: string;
|
||||||
subject?: string
|
subject?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OrganizationPlaceholderData {
|
interface OrganizationPlaceholderData {
|
||||||
name: string
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaceholderData {
|
export interface PlaceholderData {
|
||||||
subscriber: SubscriberPlaceholderData
|
subscriber: SubscriberPlaceholderData;
|
||||||
campaign: CampaignPlaceholderData
|
campaign: CampaignPlaceholderData;
|
||||||
organization: OrganizationPlaceholderData
|
organization: OrganizationPlaceholderData;
|
||||||
unsubscribe_link: string
|
unsubscribe_link: string;
|
||||||
current_date?: string
|
current_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlaceholderDataKey =
|
export type PlaceholderDataKey =
|
||||||
@@ -26,22 +26,22 @@ export type PlaceholderDataKey =
|
|||||||
| `organization.${keyof OrganizationPlaceholderData}`
|
| `organization.${keyof OrganizationPlaceholderData}`
|
||||||
| `unsubscribe_link`
|
| `unsubscribe_link`
|
||||||
| `current_date`
|
| `current_date`
|
||||||
| `subscriber.metadata.${string}`
|
| `subscriber.metadata.${string}`;
|
||||||
|
|
||||||
export function replacePlaceholders(
|
export function replacePlaceholders(
|
||||||
template: string,
|
template: string,
|
||||||
data: Partial<Record<PlaceholderDataKey, string>>
|
data: Partial<Record<PlaceholderDataKey, string>>,
|
||||||
): string {
|
): string {
|
||||||
let result = template
|
let result = template;
|
||||||
for (const key in data) {
|
for (const key in data) {
|
||||||
const placeholderRegex = new RegExp(
|
const placeholderRegex = new RegExp(
|
||||||
`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*}}`,
|
`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*}}`,
|
||||||
"g"
|
"g",
|
||||||
)
|
);
|
||||||
const value = data[key as PlaceholderDataKey]
|
const value = data[key as PlaceholderDataKey];
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
result = result.replace(placeholderRegex, value)
|
result = result.replace(placeholderRegex, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user