Compare commits

..

11 Commits

Author SHA1 Message Date
eb0b35bb7f initial commit. gotta go 2025-09-26 14:30:57 -05:00
KM Koushik
b342335502 add coderabbit as sponsor (#256) 2025-09-26 07:37:05 +10:00
Ashin T V
42377b5041 ui-fix ReputationMetrics (#249) 2025-09-21 17:48:00 +10:00
Alen Abraham
987c07db35 fixed docker setup for psql (#248) 2025-09-21 08:09:11 +10:00
Ashin T V
c6405b47d2 ui-fix topbar (#245) 2025-09-21 06:21:38 +10:00
Ashin T V
cb79be68c7 ui-fix emailchart (#244) 2025-09-21 06:19:06 +10:00
KM Koushik
5780177d26 fix: send founder email on first paid subscription (#247) 2025-09-21 06:18:16 +10:00
KM Koushik
1226e89aaf add postmortem (#241) 2025-09-19 22:09:54 +10:00
KM Koushik
2fa8c1b600 fix build 2025-09-19 08:24:08 +10:00
KM Koushik
81faba2aba add admin mail analytics (#240) 2025-09-19 08:18:23 +10:00
KM Koushik
62a15ef811 add waitlist confirmation (#239) 2025-09-19 07:26:38 +10:00
308 changed files with 7607 additions and 7597 deletions

View File

@@ -4,9 +4,9 @@ REDIS_URL="redis://redis:6379"
# Postgres - required for docker-compose, not needed for just docker
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="usesend"
POSTGRES_DB="gibsend"
# Postgres - required
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/usesend"
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/gibsend"
# NextAuth - required
NEXTAUTH_URL="http://localhost:3000"
@@ -14,7 +14,7 @@ NEXTAUTH_SECRET=
#SMTP
SMTP_HOST=smtp.mailtrap.io # Example SMTP host
SMTP_USER= "usesend" # Example SMTP user
SMTP_USER= "gibsend" # Example SMTP user
## Auth providers any one is required
# GitHub login - required
@@ -25,6 +25,11 @@ GITHUB_SECRET="<your-github-client-secret>"
GOOGLE_CLIENT_ID="<your-google-client-id>"
GOOGLE_CLIENT_SECRET="<your-google-client-secret>"
# Gib's Auth Login
GIBS_AUTH_CLIENT_ID="<your-gibs-auth-client-id>"
GIBS_AUTH_CLIENT_SECRET="<your-gibs-auth-client-secret>"
GIBS_AUTH_ISSUER="<your-gibs-auth-issuer>"
# AWS details - required
AWS_DEFAULT_REGION="us-east-1"
AWS_SECRET_KEY="<your-aws-secret-key>"

View File

@@ -41,4 +41,4 @@
- Prefer Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`). Git history shows frequent feat/fix usage.
- PRs must include: clear description, linked issues, screenshots for UI changes, migration notes, and verification steps.
- CI hygiene: ensure `pnpm lint` and `pnpm build` pass; run relevant `db:*` scripts if schema changes.
- never run build,migration commands unless asked for

View File

@@ -97,37 +97,3 @@ For detailed instructions on how to configure and run the Docker container, plea
## Self Hosting
Checkout the [self-hosting guide](https://docs.usesend.com/self-hosting/overview) to learn how to run useSend on your own infrastructure.
## Self Hosting with Railway
Railway provides the quickest way to spin up useSend. Read the [Railway self-hosting guide](https://docs.usesend.com/self-hosting/railway) or deploy directly:
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.com/deploy/usesend?utm_medium=integration&utm_source=docs&utm_campaign=usesend)
## Star History
<a href="https://star-history.com/#usesend/usesend&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=usesend/usesend&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=usesend/usesend&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=usesend/usesend&type=Date" />
</picture>
</a>
## Sponsors
We are grateful for the support of our sponsors.
### Our Sponsors
<a href="https://doras.to/" target="_blank">
<img src="https://cdn.doras.to/doras/assets/05c5db48-cfba-49d7-82a1-5b4a3751aa40/49ca4647-65ed-412e-95c6-c475633d62af.png" alt="doras.to" style="width:60px;height:60px;">
</a>
<a href="https://github.com/anaclumos" target="_blank">
<img src="https://avatars.githubusercontent.com/u/31657298?v=4" alt="anaclumos" style="width:60px;height:60px;">
</a>
<a href="https://github.com/miguilimzero" target="_blank">
<img src="https://avatars.githubusercontent.com/u/35383529?v=4" alt="miguilimzero" style="width:60px;height:60px;">
</a>

View File

@@ -1,3 +1,5 @@
import createMDX from "@next/mdx";
/** @type {import("next").NextConfig} */
const config = {
// Use static export in production by default; keep dev server dynamic
@@ -6,6 +8,11 @@ const config = {
// Required for static export if using images
unoptimized: true,
},
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};
export default config;
const withMDX = createMDX({
extension: /\.(md|mdx)$/,
});
export default withMDX(config);

View File

@@ -10,6 +10,10 @@
"lint": "eslint . --max-warnings 0"
},
"dependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^15.5.3",
"@types/mdx": "^2.0.13",
"@usesend/email-editor": "workspace:*",
"@usesend/ui": "workspace:*",
"iconoir-react": "^7.11.0",

View File

@@ -0,0 +1,21 @@
<svg width="2152" height="313" viewBox="0 0 2152 313" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_97_43)">
<path d="M470.612 107.187C479.309 91.1833 491.434 78.6662 506.984 69.7257C522.537 60.7852 540.195 56.3149 559.876 56.3149C584.032 56.3149 604.676 62.8414 621.898 75.8053C639.121 88.7692 650.537 106.472 656.337 128.913H601.868C597.827 120.24 592.027 113.714 584.645 109.154C577.178 104.594 568.747 102.359 559.256 102.359C543.971 102.359 531.672 107.723 522.181 118.541C512.695 129.359 507.95 143.844 507.95 161.903C507.95 179.964 512.695 194.447 522.181 205.265C531.672 216.083 543.971 221.448 559.256 221.448C568.747 221.448 577.178 219.213 584.645 214.653C592.113 210.093 597.827 203.566 601.868 194.894H656.337C650.537 217.335 639.029 234.948 621.898 247.823C604.676 260.697 584.032 267.134 559.876 267.134C540.195 267.134 522.537 262.664 506.984 253.724C491.434 244.783 479.309 232.356 470.612 216.441C461.914 200.527 457.609 182.378 457.609 161.903C457.609 141.43 461.914 123.191 470.612 107.187Z" fill="#171717"/>
<path d="M711.862 257.747C699.213 250.863 689.287 241.028 681.991 228.243C674.787 215.458 671.096 200.527 671.096 183.451C671.096 166.374 674.787 151.622 682.083 138.748C689.458 125.873 699.477 116.039 712.217 109.154C724.952 102.27 739.274 98.8726 755.086 98.8726C770.905 98.8726 785.221 102.27 797.961 109.154C810.702 116.039 820.714 125.873 828.096 138.748C835.478 151.622 839.077 166.553 839.077 183.451C839.077 200.349 835.386 215.279 827.918 228.153C820.45 241.028 810.346 250.863 797.52 257.747C784.694 264.631 770.377 268.029 754.473 268.029C738.569 268.029 724.424 264.631 711.776 257.747H711.862ZM778.808 213.849C785.484 206.785 788.826 196.683 788.826 183.451C788.826 170.219 785.576 160.205 779.163 153.053C772.658 145.989 764.755 142.413 755.264 142.413C745.773 142.413 737.606 145.9 731.193 152.873C724.78 159.847 721.616 170.039 721.616 183.361C721.616 196.683 724.78 206.696 731.015 213.759C737.251 220.822 745.16 224.399 754.651 224.399C764.136 224.399 772.223 220.822 778.808 213.759V213.849Z" fill="#171717"/>
<path d="M859.284 138.572C865.519 125.787 874.134 115.953 884.937 109.069C895.746 102.184 907.781 98.787 921.135 98.787C931.767 98.787 941.429 101.022 950.215 105.582C959.001 110.142 965.941 116.221 970.944 123.821V47.4678H1020.5V265.618H970.944V242.015C966.291 249.883 959.7 256.141 951.092 260.88C942.484 265.618 932.465 267.943 921.135 267.943C907.781 267.943 895.746 264.456 884.937 257.483C874.134 250.509 865.611 240.585 859.284 227.711C853.049 214.836 849.885 199.994 849.885 183.007C849.885 166.02 853.049 151.268 859.284 138.483V138.572ZM960.669 153.593C953.814 146.351 945.47 142.685 935.629 142.685C925.789 142.685 917.444 146.261 910.59 153.414C903.741 160.567 900.314 170.49 900.314 183.007C900.314 195.524 903.741 205.538 910.59 212.958C917.444 220.29 925.789 224.044 935.629 224.044C945.47 224.044 953.814 220.379 960.669 213.137C967.523 205.895 970.944 195.971 970.944 183.365C970.944 170.759 967.523 160.835 960.669 153.593Z" fill="#171717"/>
<path d="M1202.18 195.52H1090.08C1090.87 205.712 1094.12 213.581 1099.74 218.945C1105.45 224.309 1112.48 227.081 1120.74 227.081C1133.13 227.081 1141.73 221.806 1146.57 211.167H1199.28C1196.56 221.985 1191.73 231.73 1184.61 240.313C1177.58 248.985 1168.71 255.78 1158.08 260.697C1147.45 265.615 1135.58 268.029 1122.41 268.029C1106.6 268.029 1092.45 264.631 1080.15 257.747C1067.76 250.863 1058.1 241.028 1051.16 228.243C1044.22 215.458 1040.7 200.527 1040.7 183.451C1040.7 166.374 1044.13 151.443 1050.98 138.658C1057.84 125.873 1067.41 116.039 1079.8 109.154C1092.19 102.27 1106.33 98.8726 1122.41 98.8726C1138.48 98.8726 1151.93 102.18 1164.14 108.886C1176.26 115.592 1185.84 125.069 1192.69 137.496C1199.54 149.834 1202.97 164.318 1202.97 180.858C1202.97 185.597 1202.71 190.514 1202.09 195.61L1202.18 195.52ZM1152.37 167.536C1152.37 158.864 1149.47 151.98 1143.67 146.883C1137.87 141.787 1130.67 139.194 1121.97 139.194C1113.27 139.194 1106.68 141.609 1100.97 146.526C1095.26 151.443 1091.75 158.417 1090.43 167.447H1152.46L1152.37 167.536Z" fill="#171717"/>
<path d="M1329.92 265.699L1287.67 187.559H1275.8V265.699H1226.25V58.7246H1309.37C1325.44 58.7246 1339.06 61.5855 1350.4 67.3073C1361.73 73.0297 1370.16 80.8077 1375.78 90.7321C1381.41 100.656 1384.22 111.742 1384.22 123.901C1384.22 137.67 1380.44 149.919 1372.8 160.737C1365.15 171.555 1353.91 179.244 1339.06 183.714L1385.97 265.699H1330.01H1329.92ZM1275.72 151.886H1306.47C1315.51 151.886 1322.37 149.651 1326.85 145.091C1331.42 140.531 1333.7 134.183 1333.7 125.958C1333.7 117.732 1331.42 111.921 1326.85 107.362C1322.28 102.802 1315.51 100.566 1306.47 100.566H1275.72V151.886Z" fill="#171717"/>
<path d="M1408.9 138.562C1415.15 125.777 1423.75 115.942 1434.56 109.058C1445.37 102.174 1457.49 99.7598 1470.76 99.7598H1570.12V265.608H1520.57V242.362C1515.74 250.051 1509.06 256.221 1500.45 260.959C1491.84 265.697 1481.83 268.022 1470.49 268.022C1457.32 268.022 1445.37 264.535 1434.56 257.562C1423.75 250.588 1415.23 240.664 1408.9 227.79C1402.67 214.915 1399.51 200.073 1399.51 183.086C1399.51 166.099 1402.67 151.347 1408.9 138.562ZM1510.29 153.582C1503.44 146.341 1495.09 142.675 1485.26 142.675C1475.42 142.675 1467.06 146.251 1460.22 153.403C1453.36 160.556 1449.93 170.48 1449.93 182.997C1449.93 195.514 1453.36 205.527 1460.22 212.948C1467.06 220.279 1475.42 224.034 1485.26 224.034C1495.09 224.034 1503.44 220.369 1510.29 213.127C1517.14 205.885 1520.57 195.961 1520.57 183.355C1520.57 170.748 1517.14 160.824 1510.29 153.582Z" fill="#171717"/>
<path d="M1669.83 105.85C1678.53 101.111 1688.46 98.787 1699.7 98.787C1713.06 98.787 1725.09 102.184 1735.9 109.069C1746.71 115.953 1755.23 125.787 1761.56 138.572C1767.79 151.358 1770.96 166.199 1770.96 183.096C1770.96 199.994 1767.79 214.925 1761.56 227.8C1755.32 240.674 1746.71 250.598 1735.9 257.572C1725.09 264.546 1713.06 268.032 1699.7 268.032C1688.28 268.032 1678.36 265.708 1669.83 261.148C1661.31 256.499 1654.63 250.42 1649.8 242.731V265.708H1600.25V47.4678H1649.8V124.446C1654.46 116.758 1661.14 110.588 1669.83 105.85ZM1710.25 153.503C1703.4 146.351 1694.96 142.774 1684.86 142.774C1674.76 142.774 1666.67 146.44 1659.82 153.682C1652.97 160.924 1649.54 170.848 1649.54 183.454C1649.54 196.06 1652.97 205.985 1659.82 213.226C1666.67 220.468 1675.02 224.134 1684.86 224.134C1694.7 224.134 1703.13 220.468 1710.07 213.048C1717.01 205.717 1720.53 195.703 1720.53 183.096C1720.53 170.49 1717.1 160.656 1710.25 153.503Z" fill="#171717"/>
<path d="M1860.75 105.85C1869.45 101.111 1879.38 98.787 1890.62 98.787C1903.98 98.787 1916.01 102.184 1926.82 109.069C1937.62 115.953 1946.15 125.787 1952.47 138.572C1958.71 151.358 1961.87 166.199 1961.87 183.096C1961.87 199.994 1958.71 214.925 1952.47 227.8C1946.23 240.674 1937.62 250.598 1926.82 257.572C1916.01 264.546 1903.98 268.032 1890.62 268.032C1879.2 268.032 1869.27 265.708 1860.75 261.148C1852.23 256.499 1845.55 250.42 1840.72 242.731V265.708H1791.17V47.4678H1840.72V124.446C1845.37 116.758 1852.05 110.588 1860.75 105.85ZM1901.16 153.503C1894.31 146.351 1885.88 142.774 1875.77 142.774C1865.67 142.774 1857.59 146.44 1850.73 153.682C1843.89 160.924 1840.46 170.848 1840.46 183.454C1840.46 196.06 1843.89 205.985 1850.73 213.226C1857.59 220.468 1865.93 224.134 1875.77 224.134C1885.61 224.134 1894.05 220.468 1900.99 213.048C1907.93 205.717 1911.44 195.703 1911.44 183.096C1911.44 170.49 1908.02 160.656 1901.16 153.503Z" fill="#171717"/>
<path d="M1985.77 76.2514C1980.23 71.0655 1977.51 64.6286 1977.51 56.9396C1977.51 49.2507 1980.23 42.545 1985.77 37.3597C1991.3 32.1737 1998.33 29.5811 2007.02 29.5811C2015.72 29.5811 2022.49 32.1737 2028.02 37.3597C2033.56 42.545 2036.28 49.0714 2036.28 56.9396C2036.28 64.8073 2033.56 71.0655 2028.02 76.2514C2022.49 81.4368 2015.54 84.0294 2007.02 84.0294C1998.5 84.0294 1991.21 81.4368 1985.77 76.2514ZM2031.71 101.195V265.702H1982.16V101.106H2031.71V101.195Z" fill="#171717"/>
<path d="M2151.9 222.875V265.611H2126.68C2108.76 265.611 2094.71 261.141 2084.69 252.2C2074.67 243.26 2069.67 228.686 2069.67 208.391V142.946H2049.99V101.104H2069.67V61.0503H2119.22V101.104H2151.64V142.946H2119.22V209.017C2119.22 213.934 2120.36 217.511 2122.74 219.656C2125.02 221.802 2128.89 222.875 2134.33 222.875H2151.99H2151.9Z" fill="#171717"/>
<path d="M156.512 313C242.944 313 313.012 242.933 313.012 156.5C313.012 70.0674 242.944 0 156.512 0C70.0791 0 0.0117188 70.0674 0.0117188 156.5C0.0117188 242.933 70.0791 313 156.512 313Z" fill="#171717"/>
<path d="M262.787 130.577C262.787 130.577 240.982 102.703 213.572 101.109C195.884 100.065 191.599 102.428 190.831 104.188C189.733 95.0622 181.933 52.7825 128.047 43.8223C134.926 93.2713 163.335 80.3866 180.066 114.469C180.066 114.469 151.833 76.0941 105.417 90.2232C105.417 90.2232 122.335 125.74 172.375 132.997C172.375 132.997 176.385 146.741 177.594 149.161C177.594 149.161 100.528 108.971 77.1277 186.106C59.7129 182.161 53.8716 201.066 73.8876 213.98C73.8876 213.98 77.2934 200.455 85.5866 196.442C85.5866 196.442 67.7896 216.29 88.7175 240.061H163.832C165.647 237.054 173.68 221.236 153.813 209.263C167.837 209.062 179.252 235.515 191.533 240.243H209.396C210 238.774 211.264 234.376 208.296 230.419C203.722 225.172 193.707 225.882 193.796 216.179C197.255 171.04 264.939 184.902 262.787 130.577Z" fill="#FEFEFE"/>
</g>
<defs>
<clipPath id="clip0_97_43">
<rect width="2152" height="313" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,20 @@
<svg width="2152" height="313" viewBox="0 0 2152 313" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_97_68)">
<path d="M470.612 105.981C479.309 89.978 491.434 77.4609 506.984 68.5204C522.537 59.5799 540.195 55.1096 559.876 55.1096C584.032 55.1096 604.676 61.6361 621.898 74.6C639.121 87.5639 650.537 105.266 656.337 127.707H601.868C597.827 119.035 592.027 112.508 584.645 107.949C577.178 103.389 568.747 101.154 559.256 101.154C543.971 101.154 531.672 106.518 522.181 117.336C512.695 128.154 507.95 142.638 507.95 160.698C507.95 178.758 512.695 193.242 522.181 204.06C531.672 214.878 543.971 220.243 559.256 220.243C568.747 220.243 577.178 218.007 584.645 213.448C592.113 208.888 597.827 202.361 601.868 193.689H656.337C650.537 216.13 639.029 233.743 621.898 246.617C604.676 259.492 584.032 265.929 559.876 265.929C540.195 265.929 522.537 261.459 506.984 252.518C491.434 243.577 479.309 231.15 470.612 215.236C461.914 199.322 457.609 181.172 457.609 160.698C457.609 140.224 461.914 121.985 470.612 105.981Z" fill="#FEFEFE"/>
<path d="M711.862 256.542C699.213 249.658 689.287 239.823 681.991 227.038C674.787 214.253 671.096 199.322 671.096 182.246C671.096 165.169 674.787 150.417 682.083 137.543C689.459 124.668 699.477 114.833 712.217 107.949C724.952 101.065 739.274 97.6675 755.086 97.6675C770.905 97.6675 785.221 101.065 797.961 107.949C810.702 114.833 820.714 124.668 828.096 137.543C835.478 150.417 839.077 165.348 839.077 182.246C839.077 199.143 835.386 214.074 827.918 226.948C820.451 239.823 810.346 249.658 797.52 256.542C784.694 263.426 770.377 266.824 754.473 266.824C738.569 266.824 724.424 263.426 711.776 256.542H711.862ZM778.808 212.644C785.485 205.58 788.826 195.478 788.826 182.246C788.826 169.014 785.577 159 779.163 151.848C772.658 144.784 764.755 141.208 755.264 141.208C745.773 141.208 737.606 144.695 731.193 151.668C724.78 158.642 721.616 168.834 721.616 182.156C721.616 195.478 724.78 205.491 731.015 212.554C737.251 219.617 745.16 223.194 754.651 223.194C764.136 223.194 772.223 219.617 778.808 212.554V212.644Z" fill="#FEFEFE"/>
<path d="M859.284 137.367C865.519 124.582 874.133 114.748 884.937 107.863C895.746 100.979 907.781 97.5817 921.135 97.5817C931.766 97.5817 941.429 99.8164 950.215 104.376C959.001 108.936 965.941 115.016 970.944 122.615V46.2625H1020.5V264.413H970.944V240.81C966.291 248.678 959.7 254.936 951.092 259.675C942.484 264.413 932.465 266.738 921.135 266.738C907.781 266.738 895.746 263.251 884.937 256.277C874.133 249.303 865.611 239.38 859.284 226.505C853.048 213.63 849.885 198.789 849.885 181.802C849.885 164.815 853.048 150.063 859.284 137.278V137.367ZM960.668 152.387C953.814 145.146 945.469 141.48 935.629 141.48C925.788 141.48 917.444 145.056 910.589 152.209C903.741 159.361 900.313 169.285 900.313 181.802C900.313 194.319 903.741 204.333 910.589 211.753C917.444 219.084 925.788 222.839 935.629 222.839C945.469 222.839 953.814 219.173 960.668 211.932C967.523 204.69 970.944 194.766 970.944 182.159C970.944 169.553 967.523 159.629 960.668 152.387Z" fill="#FEFEFE"/>
<path d="M1202.18 194.315H1090.08C1090.87 204.507 1094.12 212.376 1099.74 217.74C1105.45 223.104 1112.48 225.876 1120.74 225.876C1133.13 225.876 1141.73 220.601 1146.57 209.962H1199.28C1196.56 220.78 1191.73 230.525 1184.61 239.108C1177.58 247.78 1168.71 254.575 1158.08 259.492C1147.45 264.41 1135.58 266.824 1122.41 266.824C1106.6 266.824 1092.45 263.426 1080.15 256.542C1067.76 249.658 1058.1 239.823 1051.16 227.038C1044.22 214.253 1040.7 199.322 1040.7 182.246C1040.7 165.169 1044.13 150.238 1050.98 137.453C1057.83 124.668 1067.41 114.833 1079.8 107.949C1092.19 101.065 1106.33 97.6675 1122.41 97.6675C1138.48 97.6675 1151.93 100.975 1164.14 107.681C1176.26 114.387 1185.84 123.864 1192.69 136.291C1199.54 148.629 1202.97 163.113 1202.97 179.653C1202.97 184.392 1202.71 189.309 1202.09 194.405L1202.18 194.315ZM1152.37 166.331C1152.37 157.659 1149.47 150.775 1143.67 145.678C1137.87 140.582 1130.67 137.989 1121.97 137.989C1113.27 137.989 1106.68 140.403 1100.97 145.321C1095.26 150.238 1091.75 157.212 1090.43 166.242H1152.46L1152.37 166.331Z" fill="#FEFEFE"/>
<path d="M1329.92 264.494L1287.67 186.353H1275.8V264.494H1226.25V57.5193H1309.37C1325.44 57.5193 1339.06 60.3802 1350.39 66.102C1361.73 71.8243 1370.16 79.6023 1375.78 89.5268C1381.41 99.4506 1384.22 110.537 1384.22 122.696C1384.22 136.465 1380.44 148.714 1372.8 159.532C1365.15 170.35 1353.91 178.039 1339.06 182.509L1385.97 264.494H1330.01H1329.92ZM1275.72 150.68H1306.47C1315.51 150.68 1322.37 148.445 1326.85 143.885C1331.42 139.326 1333.7 132.978 1333.7 124.752C1333.7 116.527 1331.42 110.716 1326.85 106.156C1322.28 101.596 1315.51 99.361 1306.47 99.361H1275.72V150.68Z" fill="#FEFEFE"/>
<path d="M1408.9 137.357C1415.15 124.572 1423.75 114.737 1434.56 107.853C1445.37 100.968 1457.49 98.5544 1470.76 98.5544H1570.12V264.403H1520.57V241.157C1515.74 248.846 1509.06 255.015 1500.46 259.754C1491.84 264.492 1481.83 266.817 1470.49 266.817C1457.32 266.817 1445.37 263.33 1434.56 256.356C1423.75 249.382 1415.23 239.459 1408.9 226.584C1402.67 213.709 1399.51 198.868 1399.51 181.881C1399.51 164.894 1402.67 150.142 1408.9 137.357ZM1510.29 152.377C1503.44 145.135 1495.09 141.47 1485.26 141.47C1475.42 141.47 1467.07 145.046 1460.22 152.198C1453.36 159.351 1449.93 169.274 1449.93 181.792C1449.93 194.309 1453.36 204.322 1460.22 211.743C1467.07 219.074 1475.42 222.829 1485.26 222.829C1495.09 222.829 1503.44 219.164 1510.29 211.921C1517.14 204.679 1520.57 194.755 1520.57 182.149C1520.57 169.543 1517.14 159.619 1510.29 152.377Z" fill="#FEFEFE"/>
<path d="M1669.83 104.645C1678.53 99.906 1688.46 97.5817 1699.7 97.5817C1713.06 97.5817 1725.09 100.979 1735.9 107.863C1746.71 114.748 1755.23 124.582 1761.56 137.367C1767.79 150.152 1770.95 164.993 1770.95 181.891C1770.95 198.789 1767.79 213.72 1761.56 226.594C1755.32 239.469 1746.71 249.393 1735.9 256.367C1725.09 263.34 1713.06 266.827 1699.7 266.827C1688.28 266.827 1678.36 264.503 1669.83 259.943C1661.31 255.294 1654.63 249.214 1649.8 241.525V264.503H1600.25V46.2625H1649.8V123.241C1654.46 115.552 1661.14 109.383 1669.83 104.645ZM1710.25 152.298C1703.4 145.146 1694.96 141.569 1684.86 141.569C1674.76 141.569 1666.67 145.235 1659.82 152.477C1652.97 159.718 1649.54 169.643 1649.54 182.249C1649.54 194.855 1652.97 204.779 1659.82 212.021C1666.67 219.263 1675.02 222.929 1684.86 222.929C1694.7 222.929 1703.13 219.263 1710.07 211.842C1717.01 204.511 1720.53 194.498 1720.53 181.891C1720.53 169.285 1717.1 159.45 1710.25 152.298Z" fill="#FEFEFE"/>
<path d="M1860.75 104.645C1869.45 99.906 1879.38 97.5817 1890.62 97.5817C1903.98 97.5817 1916.01 100.979 1926.82 107.863C1937.62 114.748 1946.15 124.582 1952.47 137.367C1958.71 150.152 1961.87 164.993 1961.87 181.891C1961.87 198.789 1958.71 213.72 1952.47 226.594C1946.23 239.469 1937.62 249.393 1926.82 256.367C1916.01 263.34 1903.98 266.827 1890.62 266.827C1879.2 266.827 1869.27 264.503 1860.75 259.943C1852.23 255.294 1845.55 249.214 1840.72 241.525V264.503H1791.17V46.2625H1840.72V123.241C1845.37 115.552 1852.05 109.383 1860.75 104.645ZM1901.16 152.298C1894.31 145.146 1885.88 141.569 1875.77 141.569C1865.67 141.569 1857.59 145.235 1850.73 152.477C1843.89 159.718 1840.46 169.643 1840.46 182.249C1840.46 194.855 1843.89 204.779 1850.73 212.021C1857.59 219.263 1865.93 222.929 1875.77 222.929C1885.61 222.929 1894.05 219.263 1900.99 211.842C1907.93 204.511 1911.44 194.498 1911.44 181.891C1911.44 169.285 1908.02 159.45 1901.16 152.298Z" fill="#FEFEFE"/>
<path d="M1985.77 75.0464C1980.23 69.8604 1977.51 63.4236 1977.51 55.7346C1977.51 48.0456 1980.23 41.3399 1985.77 36.1546C1991.3 30.9686 1998.33 28.376 2007.02 28.376C2015.72 28.376 2022.49 30.9686 2028.02 36.1546C2033.56 41.3399 2036.28 47.8664 2036.28 55.7346C2036.28 63.6022 2033.56 69.8604 2028.02 75.0464C2022.49 80.2317 2015.54 82.8243 2007.02 82.8243C1998.5 82.8243 1991.21 80.2317 1985.77 75.0464ZM2031.71 99.9903V264.497H1982.16V99.9007H2031.71V99.9903Z" fill="#FEFEFE"/>
<path d="M2151.9 221.67V264.406H2126.68C2108.76 264.406 2094.71 259.935 2084.69 250.995C2074.67 242.054 2069.67 227.481 2069.67 207.186V141.741H2049.99V99.8987H2069.67V59.845H2119.22V99.8987H2151.64V141.741H2119.22V207.812C2119.22 212.729 2120.36 216.306 2122.74 218.451C2125.02 220.597 2128.89 221.67 2134.33 221.67H2151.99H2151.9Z" fill="#FEFEFE"/>
<path d="M156.512 0C242.944 0 313.012 70.0675 313.012 156.5C313.012 242.933 242.944 313 156.512 313C70.0791 313 0.0117188 242.933 0.0117188 156.5C0.0117188 70.0675 70.0791 0 156.512 0ZM128.045 43.8221C134.924 93.2711 163.334 80.3865 180.063 114.469C180.063 114.469 151.831 76.094 105.415 90.2231C105.42 90.2345 122.341 125.741 172.372 132.997C172.378 133.016 176.384 146.743 177.592 149.16C177.592 149.16 100.526 108.971 77.1258 186.106C59.7109 182.161 53.8696 201.066 73.8854 213.98C73.8854 213.98 77.2913 200.455 85.5845 196.442C85.5514 196.478 67.8071 216.312 88.7156 240.061H163.83C165.645 237.054 173.677 221.236 153.811 209.263C167.834 209.062 179.25 235.514 191.531 240.243H209.395C209.998 238.773 211.261 234.375 208.295 230.419C203.72 225.172 193.705 225.882 193.794 216.178C197.252 171.04 264.936 184.902 262.785 130.577C262.768 130.554 240.968 102.703 213.569 101.109C195.881 100.065 191.597 102.428 190.829 104.188C189.731 95.062 181.931 52.7824 128.045 43.8221Z" fill="#FEFEFE"/>
</g>
<defs>
<clipPath id="clip0_97_68">
<rect width="2152" height="313" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -1,55 +1,55 @@
import "@usesend/ui/styles/globals.css";
import '@usesend/ui/styles/globals.css';
import { Inter } from "next/font/google";
import { JetBrains_Mono } from "next/font/google";
import type { Metadata } from "next";
import { ThemeProvider } from "@usesend/ui";
import Script from "next/script";
import { Inter } from 'next/font/google';
import { JetBrains_Mono } from 'next/font/google';
import type { Metadata } from 'next';
import { ThemeProvider } from '@usesend/ui';
import Script from 'next/script';
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
subsets: ['latin'],
variable: '--font-sans',
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
subsets: ['latin'],
variable: '--font-mono',
});
export const metadata: Metadata = {
title: "useSend Open source email platform",
description: "Pay only for what you send, not for storing contacts",
icons: [{ rel: "icon", url: "/favicon.ico" }],
metadataBase: new URL("https://usesend.com"),
title: 'useSend Open source email platform',
description: 'Pay only for what you send, not for storing contacts',
icons: [{ rel: 'icon', url: '/favicon.ico' }],
metadataBase: new URL('https://usesend.com'),
openGraph: {
title: "useSend Open source email platform",
description: "Pay only for what you send, not for storing contacts",
url: "https://usesend.com",
siteName: "useSend",
title: 'useSend Open source email platform',
description: 'Pay only for what you send, not for storing contacts',
url: 'https://usesend.com',
siteName: 'useSend',
images: [
{
url: "https://uploads.usesend.com/logos/og.png",
url: 'https://uploads.usesend.com/logos/og.png',
width: 1200,
height: 630,
alt: "useSend Open source email platform",
type: "image/png",
alt: 'useSend Open source email platform',
type: 'image/png',
},
],
locale: "en_US",
type: "website",
locale: 'en_US',
type: 'website',
},
twitter: {
card: "summary_large_image",
title: "useSend Open source email platform",
description: "Pay only for what you send, not for storing contacts",
images: ["https://uploads.usesend.com/logos/og.png"],
card: 'summary_large_image',
title: 'useSend Open source email platform',
description: 'Pay only for what you send, not for storing contacts',
images: ['https://uploads.usesend.com/logos/og.png'],
},
robots: {
index: true,
follow: true,
},
alternates: {
canonical: "https://usesend.com",
canonical: 'https://usesend.com',
},
};
@@ -62,9 +62,9 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className="scroll-smooth bg-background"
className="bg-background scroll-smooth"
>
{process.env.NODE_ENV === "production" && (
{process.env.NODE_ENV === 'production' && (
<Script src="https://scripts.simpleanalyticscdn.com/latest.js" />
)}
<body

View File

@@ -1,21 +1,21 @@
import Image from "next/image";
import Link from "next/link";
import { SiteFooter } from "~/components/SiteFooter";
import { GitHubStarsButton } from "~/components/GitHubStarsButton";
import { Button } from "@usesend/ui/src/button";
import { TopNav } from "~/components/TopNav";
import { FeatureCard } from "~/components/FeatureCard";
import { FeatureCardPlain } from "~/components/FeatureCardPlain";
import { PricingCalculator } from "~/components/PricingCalculator";
import CodeExample from "~/components/CodeExample";
import Image from 'next/image';
import Link from 'next/link';
import { SiteFooter } from '~/components/SiteFooter';
import { GitHubStarsButton } from '~/components/GitHubStarsButton';
import { Button } from '@usesend/ui/src/button';
import { TopNav } from '~/components/TopNav';
import { FeatureCard } from '~/components/FeatureCard';
import { FeatureCardPlain } from '~/components/FeatureCardPlain';
import { PricingCalculator } from '~/components/PricingCalculator';
import CodeExample from '~/components/CodeExample';
const REPO = "usesend/usesend";
const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com";
const APP_URL = 'https://app.usesend.com';
export default function Page() {
return (
<main className="min-h-screen text-foreground bg-background">
<main className="text-foreground bg-background min-h-screen">
<TopNav />
<Hero />
<TrustedBy />
@@ -34,18 +34,18 @@ function Hero() {
return (
<section>
<div className="mx-auto max-w-6xl px-6 py-16 sm:py-24">
<h1 className="mt-6 text-center text-2xl sm:text-4xl font-semibold text-primary font-sans">
<h1 className="text-primary mt-6 text-center font-sans text-2xl font-semibold sm:text-4xl">
The open source email platform for everyone
</h1>
<p className="mt-4 text-center text-base sm:text-lg font-sans max-w-2xl mx-auto">
Send product, transactional and marketing emails.{" "}
<p className="mx-auto mt-4 max-w-2xl text-center font-sans text-base sm:text-lg">
Send product, transactional and marketing emails.{' '}
<span className="text-primary font-normal">
Pay only for what you send
</span>{" "}
</span>{' '}
and not for storing contacts.
</p>
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Button size="lg" className="px-6">
<a href={APP_URL} target="_blank" rel="noopener noreferrer">
Get started
@@ -55,19 +55,49 @@ function Hero() {
<GitHubStarsButton />
</div>
<p className="mt-3 text-center text-xs text-muted-foreground">
<p className="text-muted-foreground mt-3 text-center text-xs">
Open source Self-host in minutes Free tier
</p>
<div className=" mt-32 mx-auto max-w-5xl">
<div className="rounded-[18px] bg-primary/10 p-1 sm:p-1 ">
<div className="rounded-2xl bg-primary/20 p-1 sm:p-1 ">
<div className="text-muted-foreground mt-12 flex flex-col items-center justify-center gap-2 text-center text-xs">
<p className="text-xs">Proudly sponsored by</p>
<a
href="https://coderabbit.ai/?utm_source=useSend.com"
target="_blank"
>
<Image
src="/code-rabbit-usesend-dark.svg"
alt="Code Rabbit"
width={200}
height={100}
className="dark:hidden"
rel="noopener noreferrer"
/>
</a>
<a
href="https://coderabbit.ai/?utm_source=useSend.com"
target="_blank"
>
<Image
src="/code-rabbit-usesend-light.svg"
alt="Code Rabbit"
width={200}
height={100}
className="hidden dark:block"
rel="noopener noreferrer"
/>
</a>
</div>
<div className="mx-auto mt-32 max-w-5xl">
<div className="bg-primary/10 rounded-[18px] p-1 sm:p-1">
<div className="bg-primary/20 rounded-2xl p-1 sm:p-1">
<Image
src="/hero-light.webp"
alt="useSend product hero"
width={3456}
height={1914}
className="w-full h-auto rounded-xl block dark:hidden"
className="block h-auto w-full rounded-xl dark:hidden"
sizes="(min-width: 1024px) 900px, 100vw"
loading="eager"
priority={false}
@@ -77,7 +107,7 @@ function Hero() {
alt="useSend product hero"
width={3456}
height={1914}
className="w-full h-auto rounded-xl hidden dark:block"
className="hidden h-auto w-full rounded-xl dark:block"
sizes="(min-width: 1024px) 900px, 100vw"
loading="eager"
priority={false}
@@ -97,61 +127,61 @@ function TrustedBy() {
{
quote:
"Transitioned recently to open source email sender useSend for our 30k and growing newsletter. It's such a great product and amazing oss experience.",
author: "Marc Seitz",
company: "papermark.com",
author: 'Marc Seitz',
company: 'papermark.com',
image:
"https://pbs.twimg.com/profile_images/1176854646343852032/iYnUXJ-m_400x400.jpg",
'https://pbs.twimg.com/profile_images/1176854646343852032/iYnUXJ-m_400x400.jpg',
},
{
quote:
"useSend was extremely easy to set up, and I love that it's open source. Koushik has been an absolute awesome person to deal with and helps us with any issues or feedback.",
author: "Tommerty",
company: "doras.to",
author: 'Tommerty',
company: 'doras.to',
image:
"https://cdn.doras.to/doras/user/83bda65b-8d42-4011-9bf0-ab23402776f2-0.890688178917765.webp",
'https://cdn.doras.to/doras/user/83bda65b-8d42-4011-9bf0-ab23402776f2-0.890688178917765.webp',
},
];
const quick = [
{
quote: "don't sleep on useSend",
author: "shellscape",
company: "jsx.email",
author: 'shellscape',
company: 'jsx.email',
image:
"https://pbs.twimg.com/profile_images/1698447401781022720/b0DZSc_D_400x400.jpg",
'https://pbs.twimg.com/profile_images/1698447401781022720/b0DZSc_D_400x400.jpg',
},
{
quote: "Thank you for making useSend!",
author: "Andras Bacsai",
company: "coolify.io",
quote: 'Thank you for making useSend!',
author: 'Andras Bacsai',
company: 'coolify.io',
image:
"https://pbs.twimg.com/profile_images/1884210412524027905/jW4NB4rx_400x400.jpg",
'https://pbs.twimg.com/profile_images/1884210412524027905/jW4NB4rx_400x400.jpg',
},
{
quote: "I KNOW WHAT TO DO",
author: "VicVijayakumar",
company: "onetimefax.com",
quote: 'I KNOW WHAT TO DO',
author: 'VicVijayakumar',
company: 'onetimefax.com',
image:
"https://pbs.twimg.com/profile_images/1665351804685524995/W4BpDx5Z_400x400.jpg",
'https://pbs.twimg.com/profile_images/1665351804685524995/W4BpDx5Z_400x400.jpg',
},
];
return (
<section className="py-10 sm:py-20 ">
<section className="py-10 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="text-center tracking-wider text-muted-foreground">
<div className="text-muted-foreground text-center tracking-wider">
<span className="">Builders and open source teams love </span>
<span className="text-primary font-bold">useSend</span>
</div>
{/* Top: 2 larger testimonials */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
{featured.map((t) => (
<figure
key={t.author + t.company}
className="rounded-xl border border-primary/30 p-5 h-full"
className="border-primary/30 h-full rounded-xl border p-5"
>
<blockquote className="text-sm sm:text-base font-light font-sans ">
<blockquote className="font-sans text-sm font-light sm:text-base">
{t.quote}
</blockquote>
<div className="mt-5 flex items-center gap-3">
@@ -160,7 +190,7 @@ function TrustedBy() {
alt={`${t.author} avatar`}
width={32}
height={32}
className=" rounded-md border-2 border-primary/50"
className="border-primary/50 rounded-md border-2"
/>
<figcaption className="text-sm">
<span className="font-medium">{t.author}</span>
@@ -169,9 +199,9 @@ function TrustedBy() {
target="_blank"
className="text-muted-foreground hover:text-primary-light"
>
{" "}
{' '}
{t.company}
</a>{" "}
</a>{' '}
</figcaption>
</div>
</figure>
@@ -179,13 +209,13 @@ function TrustedBy() {
</div>
{/* Bottom: 3 multi-line testimonials (same style as top) */}
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
{quick.map((t) => (
<figure
key={t.author + t.company}
className="rounded-xl border border-primary/30 p-5 h-full"
className="border-primary/30 h-full rounded-xl border p-5"
>
<blockquote className="text-sm sm:text-base font-light font-sans leading-relaxed">
<blockquote className="font-sans text-sm font-light leading-relaxed sm:text-base">
{t.quote}
</blockquote>
<div className="mt-5 flex items-center gap-3">
@@ -194,7 +224,7 @@ function TrustedBy() {
alt={`${t.author} avatar`}
width={32}
height={32}
className=" rounded-md border-2 border-primary/50"
className="border-primary/50 rounded-md border-2"
/>
<figcaption className="text-sm">
<span className="font-medium">{t.author}</span>
@@ -203,7 +233,7 @@ function TrustedBy() {
target="_blank"
className="text-muted-foreground hover:text-primary-light"
>
{" "}
{' '}
{t.company}
</a>
</figcaption>
@@ -220,42 +250,42 @@ function Features() {
// Top: 2 cards (with image area) — Analytics, Editor
const top = [
{
key: "feature-analytics",
title: "Analytics",
key: 'feature-analytics',
title: 'Analytics',
content:
"Track deliveries, opens, clicks, bounces and unsubscribes in real time with a simple, searchable log. Filter by domain, status, api key and export them. Track which campaigns perform best.",
imageLightSrc: "/emails-search-light.webp",
imageDarkSrc: "/emails-search-dark.webp",
'Track deliveries, opens, clicks, bounces and unsubscribes in real time with a simple, searchable log. Filter by domain, status, api key and export them. Track which campaigns perform best.',
imageLightSrc: '/emails-search-light.webp',
imageDarkSrc: '/emails-search-dark.webp',
},
{
key: "feature-editor",
title: "Marketing Email Editor",
key: 'feature-editor',
title: 'Marketing Email Editor',
content:
"Design beautiful campaigns without code using a visual, notion like WYSIWYG editor that works in major email clients. Reuse templates and brand styles, and personalize with variables.",
imageLightSrc: "/editor-light.webp",
imageDarkSrc: "/editor-dark.webp",
'Design beautiful campaigns without code using a visual, notion like WYSIWYG editor that works in major email clients. Reuse templates and brand styles, and personalize with variables.',
imageLightSrc: '/editor-light.webp',
imageDarkSrc: '/editor-dark.webp',
},
];
// Bottom: 3 cards (no images) — Contact Management, Suppression List, SMTP Relay Service
const bottom = [
{
key: "feature-contacts",
title: "Contact Management",
key: 'feature-contacts',
title: 'Contact Management',
content:
"Manage contacts, lists, and consent in one place. Import and export easily, keep per-list subscription status. Contacts are automatically updated from bounces and complaints.",
'Manage contacts, lists, and consent in one place. Import and export easily, keep per-list subscription status. Contacts are automatically updated from bounces and complaints.',
},
{
key: "feature-suppression",
title: "Suppression List",
key: 'feature-suppression',
title: 'Suppression List',
content:
"Prevent accidental sends. Automatically populated from bounces and complaints, and manage via import/export or API. Works with transactional and marketing emails.",
'Prevent accidental sends. Automatically populated from bounces and complaints, and manage via import/export or API. Works with transactional and marketing emails.',
},
{
key: "feature-smtp",
title: "SMTP Relay",
key: 'feature-smtp',
title: 'SMTP Relay',
content:
"Drop-in SMTP relay that works with any app or framework. Do not get vendor lock-in. Comes in handy with services like Supabase",
'Drop-in SMTP relay that works with any app or framework. Do not get vendor lock-in. Comes in handy with services like Supabase',
},
];
@@ -263,13 +293,13 @@ function Features() {
<section id="features" className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="text-center">
<div className="mb-2 text-sm uppercase tracking-wider text-primary">
<div className="text-primary mb-2 text-sm uppercase tracking-wider">
Features
</div>
</div>
{/* Top row: 2 side-by-side cards with images */}
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
{top.map((f) => (
<FeatureCard
key={f.key}
@@ -282,7 +312,7 @@ function Features() {
</div>
{/* Bottom row: 3 cards without images */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-3">
{bottom.map((f) => (
<FeatureCardPlain key={f.key} title={f.title} content={f.content} />
))}
@@ -296,35 +326,35 @@ function Features() {
function Pricing() {
const freePerks = [
"Send up to 3000 emails per month",
"Send up to 100 emails per day",
"Can have 1 contact book",
"Can have 1 domain",
"Can have 1 team member",
'Send up to 3000 emails per month',
'Send up to 100 emails per day',
'Can have 1 contact book',
'Can have 1 domain',
'Can have 1 team member',
];
const paidPerks = [
"$10 monthly usage credits",
"Send transactional emails at $0.0004 per email",
"Send marketing emails at $0.001 per email",
"Can have unlimited contact books",
"Can have unlimited domains",
"Can have unlimited team members",
'$10 monthly usage credits',
'Send transactional emails at $0.0004 per email',
'Send marketing emails at $0.001 per email',
'Can have unlimited contact books',
'Can have unlimited domains',
'Can have unlimited team members',
];
return (
<section id="pricing" className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="text-center">
<div className="mb-2 text-sm uppercase tracking-wider text-primary">
<div className="text-primary mb-2 text-sm uppercase tracking-wider">
PRICING
</div>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground max-w-2xl mx-auto">
<p className="text-muted-foreground mx-auto mt-1 max-w-2xl text-xs sm:text-sm">
pay for what you use, the most affordable email platform
</p>
</div>
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2">
<PricingCard
title="Free"
price="$0"
@@ -356,16 +386,16 @@ type PricingCardProps = {
function PricingCard({ title, price, note, perks }: PricingCardProps) {
return (
<div className="rounded-[18px] bg-primary/20 p-1">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col p-5">
<h3 className=" font-medium">{title}</h3>
<div className="mt-2 text-4xl text-primary">{price}</div>
<div className="text-xs text-muted-foreground">{note}</div>
<ul className="mt-4 space-y-2 text-sm mb-20">
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 h-full rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background flex h-full flex-col rounded-xl p-5">
<h3 className="font-medium">{title}</h3>
<div className="text-primary mt-2 text-4xl">{price}</div>
<div className="text-muted-foreground text-xs">{note}</div>
<ul className="mb-20 mt-4 space-y-2 text-sm">
{perks.map((perk) => (
<li key={perk} className="flex items-start gap-2">
<CheckIcon className="w-4 h-4 mt-0.5 text-primary" />
<CheckIcon className="text-primary mt-0.5 h-4 w-4" />
<span>{perk}</span>
</li>
))}
@@ -392,12 +422,12 @@ function About() {
<section id="about" className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="text-center">
<div className="mb-2 text-sm uppercase tracking-wider text-primary">
<div className="text-primary mb-2 text-sm uppercase tracking-wider">
About
</div>
</div>
<div className="mt-8 max-w-3xl mx-auto text-sm sm:text-base space-y-4">
<div className="mx-auto mt-8 max-w-3xl space-y-4 text-sm sm:text-base">
<p>
As most of email products out there, useSend also uses Amazon SES
under the hood to send emails. We provide an open and alternative
@@ -405,7 +435,7 @@ function About() {
</p>
<p>
useSend is bootstrapped and funded by the cloud offering and
sponsors. If you self host useSend, please consider{" "}
sponsors. If you self host useSend, please consider{' '}
<a
href="https://github.com/sponsors/KMKoushik"
target="_blank"
@@ -426,7 +456,7 @@ function About() {
// Footer moved to ~/components/SiteFooter
// Minimal inline icons (stroke-based, sleek)
function CheckIcon({ className = "" }: { className?: string }) {
function CheckIcon({ className = '' }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,17 +1,17 @@
import type { Metadata } from "next";
import { TopNav } from "~/components/TopNav";
import type { Metadata } from 'next';
import { TopNav } from '~/components/TopNav';
export const metadata: Metadata = {
title: "Privacy Policy useSend",
description: "Simple privacy policy for the useSend marketing site.",
title: 'Privacy Policy useSend',
description: 'Simple privacy policy for the useSend marketing site.',
};
export default function PrivacyPage() {
return (
<main className="min-h-screen bg-sidebar-background text-foreground">
<main className="bg-sidebar-background text-foreground min-h-screen">
<TopNav />
<div className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-3xl font-semibold tracking-tight mb-6">
<h1 className="mb-6 text-3xl font-semibold tracking-tight">
Privacy Policy
</h1>
<p className="text-muted-foreground mb-8">
@@ -22,7 +22,7 @@ export default function PrivacyPage() {
occasional marketing emails.
</p>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Who We Are</h2>
<p className="text-muted-foreground">
useSend ("we", "us") operates the marketing website at
@@ -41,13 +41,13 @@ export default function PrivacyPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">What We Collect</h2>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>
<span className="text-foreground">
Usage and device data (marketing site):
</span>{" "}
</span>{' '}
We use Simple Analytics to understand overall traffic and usage
patterns (e.g., pages visited, referrers, device type). Simple
Analytics is a privacyfriendly analytics provider and does not
@@ -55,7 +55,7 @@ export default function PrivacyPage() {
identify you.
</li>
<li>
<span className="text-foreground">Server and security logs:</span>{" "}
<span className="text-foreground">Server and security logs:</span>{' '}
Our hosting providers (Vercel for the marketing site; Railway for
the app) may process IP addresses and basic request metadata
transiently for security, reliability, and debugging.
@@ -63,7 +63,7 @@ export default function PrivacyPage() {
<li>
<span className="text-foreground">
Account and email data (product):
</span>{" "}
</span>{' '}
If you sign up for useSend, we process your account information
and send transactional emails. If you opt in, we may also send
occasional marketing emails. You can unsubscribe at any time via
@@ -72,9 +72,9 @@ export default function PrivacyPage() {
</ul>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">How We Use Information</h2>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Operate, secure, and maintain the marketing site and app.</li>
<li>
Understand aggregated usage to improve performance and content.
@@ -87,7 +87,7 @@ export default function PrivacyPage() {
</ul>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Legal Bases</h2>
<p className="text-muted-foreground">
Where applicable (e.g., in the EEA/UK), we rely on legitimate
@@ -97,13 +97,13 @@ export default function PrivacyPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Sharing and Processors</h2>
<p className="text-muted-foreground">
We share information with service providers who process data on our
behalf, including:
</p>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>
<span className="text-foreground">Hosting:</span> Vercel
(marketing site) and Railway (application) for serving content,
@@ -127,7 +127,7 @@ export default function PrivacyPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Retention</h2>
<p className="text-muted-foreground">
We retain information only for as long as necessary to fulfill the
@@ -137,7 +137,7 @@ export default function PrivacyPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">International Transfers</h2>
<p className="text-muted-foreground">
Our providers may process data in locations outside of your country
@@ -146,7 +146,7 @@ export default function PrivacyPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Your Rights</h2>
<p className="text-muted-foreground">
Depending on your location, you may have rights to access, correct,
@@ -158,7 +158,7 @@ export default function PrivacyPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Contact</h2>
<p className="text-muted-foreground">
For privacy requests or questions, email us at
@@ -172,7 +172,7 @@ export default function PrivacyPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Children</h2>
<p className="text-muted-foreground">
Our services are not directed to children, and we do not knowingly
@@ -180,7 +180,7 @@ export default function PrivacyPage() {
</p>
</section>
<section className="space-y-3 mb-10">
<section className="mb-10 space-y-3">
<h2 className="text-xl font-medium">Changes</h2>
<p className="text-muted-foreground">
We may update this policy from time to time. The "Last updated" date
@@ -188,7 +188,7 @@ export default function PrivacyPage() {
</p>
</section>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
Last updated: {new Date().toLocaleDateString()}
</p>
</div>

View File

@@ -1,17 +1,19 @@
import type { Metadata } from "next";
import { TopNav } from "~/components/TopNav";
import type { Metadata } from 'next';
import { TopNav } from '~/components/TopNav';
export const metadata: Metadata = {
title: "Terms of Service useSend",
description: "Terms governing use of the useSend website and product.",
title: 'Terms of Service useSend',
description: 'Terms governing use of the useSend website and product.',
};
export default function TermsPage() {
return (
<main className="min-h-screen bg-sidebar-background text-foreground">
<main className="bg-sidebar-background text-foreground min-h-screen">
<TopNav />
<div className="mx-auto max-w-3xl px-6 py-16">
<h1 className="text-3xl font-semibold tracking-tight mb-6">Terms of Service</h1>
<h1 className="mb-6 text-3xl font-semibold tracking-tight">
Terms of Service
</h1>
<p className="text-muted-foreground mb-6">
These Terms of Service ("Terms") govern your access to and use of the
useSend marketing website at usesend.com and the useSend application.
@@ -19,7 +21,7 @@ export default function TermsPage() {
these Terms.
</p>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Eligibility & Accounts</h2>
<p className="text-muted-foreground">
You may use the site and product only if you can form a binding
@@ -29,13 +31,13 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Acceptable Use</h2>
<p className="text-muted-foreground">
You agree not to misuse the site or product. Prohibited conduct
includes, without limitation:
</p>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<ul className="text-muted-foreground list-disc space-y-2 pl-5">
<li>Violating any applicable laws or regulations.</li>
<li>Infringing the rights of others or violating their privacy.</li>
<li>Attempting to interfere with or disrupt the services.</li>
@@ -46,7 +48,7 @@ export default function TermsPage() {
</ul>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Anti-Spam Enforcement</h2>
<p className="text-muted-foreground">
To protect our community, we may suspend or block access for any
@@ -55,14 +57,14 @@ export default function TermsPage() {
may include immediate account suspension or termination.
</p>
<p className="text-muted-foreground">
Marketing communications sent via useSend must employ double
opt-in verification. Accounts that bypass double opt-in or misuse
our transactional mail API for promotional campaigns may be
suspended or terminated without notice.
Marketing communications sent via useSend must employ double opt-in
verification. Accounts that bypass double opt-in or misuse our
transactional mail API for promotional campaigns may be suspended or
terminated without notice.
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Intellectual Property</h2>
<p className="text-muted-foreground">
Content on the site, including trademarks, logos, text, and
@@ -72,7 +74,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">ThirdParty Links</h2>
<p className="text-muted-foreground">
The site may contain links to thirdparty websites or services we do
@@ -80,7 +82,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Disclaimer</h2>
<p className="text-muted-foreground">
The site is provided on an "as is" and "as available" basis without
@@ -88,7 +90,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Limitation of Liability</h2>
<p className="text-muted-foreground">
To the fullest extent permitted by law, useSend shall not be liable
@@ -97,7 +99,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Indemnification</h2>
<p className="text-muted-foreground">
You agree to indemnify and hold harmless useSend from any claims,
@@ -106,7 +108,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Changes & Availability</h2>
<p className="text-muted-foreground">
We may modify these Terms and update the site or product at any
@@ -115,7 +117,7 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-8">
<section className="mb-8 space-y-3">
<h2 className="text-xl font-medium">Governing Law</h2>
<p className="text-muted-foreground">
These Terms are governed by applicable laws without regard to
@@ -125,15 +127,23 @@ export default function TermsPage() {
</p>
</section>
<section className="space-y-3 mb-10">
<section className="mb-10 space-y-3">
<h2 className="text-xl font-medium">Contact</h2>
<p className="text-muted-foreground">
Questions about these Terms? Contact us at
<a href="mailto:hey@usesend.com" className="ml-1 underline decoration-dotted">hey@usesend.com</a>.
<a
href="mailto:hey@usesend.com"
className="ml-1 underline decoration-dotted"
>
hey@usesend.com
</a>
.
</p>
</section>
<p className="text-xs text-muted-foreground">Last updated: {new Date().toLocaleDateString()}</p>
<p className="text-muted-foreground text-xs">
Last updated: {new Date().toLocaleDateString()}
</p>
</div>
</main>
);

View File

@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
import { SiteFooter } from '~/components/SiteFooter';
import { TopNav } from '~/components/TopNav';
export default function UpdateLayout({ children }: { children: ReactNode }) {
return (
<main className="bg-background text-foreground min-h-screen">
<TopNav />
<div className="mx-auto w-full max-w-3xl px-6 py-16">
<article className="space-y-8">{children}</article>
</div>
<SiteFooter />
</main>
);
}

View File

@@ -0,0 +1,66 @@
export const metadata = {
title: "September Outage Update | useSend",
description:
"What happened during the September outage, how we responded, and the improvements now in motion.",
alternates: {
canonical: "https://usesend.com/update/september-outage",
},
openGraph: {
title: "September Outage Update | useSend",
description:
"What happened during the September outage, how we responded, and the improvements now in motion.",
type: "article",
url: "https://usesend.com/update/september-outage",
},
twitter: {
card: "summary_large_image",
title: "September Outage Update | useSend",
description:
"What happened during the September outage, how we responded, and the improvements now in motion.",
},
};
# September Outage Postmortem
On September 17, starting at 11:25 UTC, our emails were not being sent and the outage lasted for almost 10 hours until 21:00 UTC. No emails were sent during this time.
## What happened
Our Amazon SES sending was temporarily paused after compliance signals indicated potential spam characteristics. The initial feedback suggested that some marketing emails might not meet common anti-spam standards (for example, missing unsubscribe links).
## Timeline tldr; (UTC)
- **11:25** - Received an email that our account's sending was paused without prior warning.
- **11:43** - Identified the problematic account, blocked it, and replied to AWS. Paused new signups as well.
- **13:00** - No reply yet, so created a separate escalated support case to get on a call.
- **14:00** - Initial response appeared to interpret us as the sender of the flagged emails; sending was not yet resumed.
- **14:11** - Clarified our product offering again, noting we had blocked the account and paused signups. Shared the useSend site, GitHub, etc.
- **15:53** - Similar response; only valid point is that some marketing emails lacked an unsubscribe link.
- **17:40** - Shipped a change making an unsubscribe link mandatory for marketing emails; shared details on the fix and existing rate limits.
- **18:36** - AWS still not clear with my product and suggestions included adding a CAPTCHA to a form (not applicable to our current flow).
- **19:18** - Re-explained the product and requested senior review for clearer alignment.
- **19:45** - AWS informed that the case would be reviewed within 2-3 business days.
- **19:48** - Requested expedited review due to user impact.
- **21:00** - Finally a valid response with actual steps to improve the product and resumed the account.
## Why
The pause highlighted areas where we can be more diligent about what gets sent through useSend and ensure alignment with SES guidelines and broader email standards.
## What's done till now
- Added a waitlist on signup; we'll screen users before enabling sending.
- Made the unsubscribe link mandatory in the marketing email editor.
- Focusing on users sending transactional and product emails for now.
## Long-term improvements
- More monitoring and pre-send checks (including email screening).
- Double opt-in for contacts.
- A backup SES account to improve resilience.
- Considering a BYO SES option, with useSend managing it for a flat fee.
- Exploring a move to a self-hosted email server (Hard but will try my best).
## To my users
Thank you for being patient and supporting during this time. I'll do a better job in the future to avoid such issues. If you have any suggestions, please do send them in discord or [koushik@usesend.com](mailto:koushik@usesend.com).

View File

@@ -1,6 +1,6 @@
import { Button } from "@usesend/ui/src/button";
import { CodeBlock } from "@usesend/ui/src/code-block";
import { LangToggle } from "./CodeLangToggle";
import { Button } from '@usesend/ui/src/button';
import { CodeBlock } from '@usesend/ui/src/code-block';
import { LangToggle } from './CodeLangToggle';
const TS_CODE = `import { UseSend } from "usesend-js";
@@ -82,34 +82,34 @@ if ($response === false) {
curl_close($ch);`;
export function CodeExample() {
const containerId = "code-example";
const containerId = 'code-example';
const languages = [
{
key: "ts",
label: "TypeScript",
kind: "ts",
shiki: "typescript" as const,
key: 'ts',
label: 'TypeScript',
kind: 'ts',
shiki: 'typescript' as const,
code: TS_CODE,
},
{
key: "py",
label: "Python",
kind: "py",
shiki: "python" as const,
key: 'py',
label: 'Python',
kind: 'py',
shiki: 'python' as const,
code: PY_CODE,
},
{
key: "go",
label: "Go",
kind: "go",
shiki: "go" as const,
key: 'go',
label: 'Go',
kind: 'go',
shiki: 'go' as const,
code: GO_CODE,
},
{
key: "php",
label: "PHP",
kind: "php",
shiki: "php" as const,
key: 'php',
label: 'PHP',
kind: 'php',
shiki: 'php' as const,
code: PHP_CODE,
},
];
@@ -118,17 +118,17 @@ export function CodeExample() {
<section className="py-16 sm:py-20">
<div className="mx-auto max-w-6xl px-6">
<div className="text-center">
<div className="mb-2 text-sm uppercase tracking-wider text-primary">
<div className="text-primary mb-2 text-sm uppercase tracking-wider">
Developers
</div>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground max-w-2xl mx-auto">
<p className="text-muted-foreground mx-auto mt-1 max-w-2xl text-xs sm:text-sm">
Typed SDKs and simple APIs, so you can focus on product not
plumbing.
</p>
</div>
<div className="mt-8 overflow-hidden" id={containerId}>
<div className="flex items-center gap-2 justify-center py-2 text-xs text-muted-foreground mb-4">
<div className="text-muted-foreground mb-4 flex items-center justify-center gap-2 py-2 text-xs">
<LangToggle
containerId={containerId}
defaultLang="ts"
@@ -139,19 +139,19 @@ export function CodeExample() {
}))}
/>
</div>
<div className="rounded-[18px] bg-primary/20 p-1">
<div className="rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-background rounded-xl overflow-hidden">
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background overflow-hidden rounded-xl">
{languages.map((l, idx) => (
<div
key={l.key}
data-lang-slot={l.key}
className={idx === 0 ? "block" : "hidden"}
className={idx === 0 ? 'block' : 'hidden'}
>
{/* Cast to any to align with shiki BundledLanguage without importing types here */}
<CodeBlock
lang={l.shiki as any}
className="p-4 rounded-[10px]"
className="rounded-[10px] p-4"
>
{l.code}
</CodeBlock>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import Image from "next/image";
import { Button } from "@usesend/ui/src/button";
import { useEffect, useState } from 'react';
import Image from 'next/image';
import { Button } from '@usesend/ui/src/button';
type LangItem = {
key: string;
label: string;
kind: "ts" | "py" | string; // used for icon selection
kind: 'ts' | 'py' | string; // used for icon selection
};
export function LangToggle({
@@ -26,38 +26,36 @@ export function LangToggle({
if (!container) return;
const slots = Array.from(
container.querySelectorAll<HTMLElement>("[data-lang-slot]")
container.querySelectorAll<HTMLElement>('[data-lang-slot]'),
);
for (const el of slots) {
const key = el.getAttribute("data-lang-slot");
const key = el.getAttribute('data-lang-slot');
if (key === active) {
el.classList.remove("hidden");
el.classList.add("block");
el.classList.remove('hidden');
el.classList.add('block');
} else {
el.classList.add("hidden");
el.classList.remove("block");
el.classList.add('hidden');
el.classList.remove('block');
}
}
}, [active, containerId]);
return (
<div className="flex items-center gap-2 justify-center">
<div className="flex items-center justify-center gap-2">
{languages.map((l) => (
<Button
key={l.key}
size="sm"
variant="outline"
className={
"px-3 bg-transparent hover:bg-transparent hover:text-inherit " +
(active === l.key
? "border-primary"
: "border-input")
'bg-transparent px-3 hover:bg-transparent hover:text-inherit ' +
(active === l.key ? 'border-primary' : 'border-input')
}
aria-pressed={active === l.key}
onClick={() => setActive(l.key)}
>
<span className="inline-flex items-center">
<LangIcon kind={l.kind} className="h-4 w-4 mr-1" /> {l.label}
<LangIcon kind={l.kind} className="mr-1 h-4 w-4" /> {l.label}
</span>
</Button>
))}
@@ -65,16 +63,27 @@ export function LangToggle({
);
}
function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: string }) {
function LangIcon({
kind,
className = 'h-4 w-4',
}: {
kind: string;
className?: string;
}) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className} role="img">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
role="img"
>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" />
</svg>
);
}
if (kind === "ts")
if (kind === 'ts')
return (
<Image
src="/typescript.svg"
@@ -86,7 +95,7 @@ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: s
onError={() => setFailed(true)}
/>
);
if (kind === "py")
if (kind === 'py')
return (
<Image
src="/python.svg"
@@ -98,7 +107,7 @@ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: s
onError={() => setFailed(true)}
/>
);
if (kind === "go")
if (kind === 'go')
return (
<Image
src="/go.svg"
@@ -110,7 +119,7 @@ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: s
onError={() => setFailed(true)}
/>
);
if (kind === "php")
if (kind === 'php')
return (
<Image
src="/php.svg"
@@ -123,7 +132,12 @@ function LangIcon({ kind, className = "h-4 w-4" }: { kind: string; className?: s
/>
);
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className={className} role="img">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={className}
role="img"
>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" />
</svg>
);

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import Image from "next/image";
import Image from 'next/image';
type FeatureCardProps = {
title?: string;
@@ -21,33 +21,33 @@ export function FeatureCard({
imageSrc,
}: FeatureCardProps) {
return (
<div className="rounded-[18px] bg-primary/20 p-1 ">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col">
<div className="relative w-full aspect-[16/9] rounded-t-xl overflow-hidden">
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 h-full rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background flex h-full flex-col rounded-xl">
<div className="relative aspect-[16/9] w-full overflow-hidden rounded-t-xl">
{imageLightSrc || imageDarkSrc ? (
<>
<Image
src={(imageLightSrc || imageDarkSrc)!}
alt={title || "Feature image"}
alt={title || 'Feature image'}
fill
className="object-cover dark:hidden rounded-t-xl"
className="rounded-t-xl object-cover dark:hidden"
priority={false}
/>
<Image
src={(imageDarkSrc || imageLightSrc)!}
alt={title || "Feature image"}
alt={title || 'Feature image'}
fill
className="object-cover hidden dark:block rounded-t-xl"
className="hidden rounded-t-xl object-cover dark:block"
priority={false}
/>
</>
) : imageSrc ? (
<Image
src={imageSrc}
alt={title || "Feature image"}
alt={title || 'Feature image'}
fill
className="object-cover rounded-t-xl"
className="rounded-t-xl object-cover"
priority={false}
/>
) : (
@@ -56,29 +56,29 @@ export function FeatureCard({
src="/hero-light.png"
alt="Feature image"
fill
className="object-cover dark:hidden rounded-t-xl"
className="rounded-t-xl object-cover dark:hidden"
priority={false}
/>
<Image
src="/hero-dark.png"
alt="Feature image"
fill
className="object-cover hidden dark:block rounded-t-xl"
className="hidden rounded-t-xl object-cover dark:block"
priority={false}
/>
</>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 sm:h-16 bg-gradient-to-b from-transparent via-background/60 to-background" />
<div className="via-background/60 to-background pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-gradient-to-b from-transparent sm:h-16" />
</div>
<div className="p-5 flex-1 flex flex-col">
<h3 className="text-base sm:text-lg text-primary font-sans">
{title || ""}
<div className="flex flex-1 flex-col p-5">
<h3 className="text-primary font-sans text-base sm:text-lg">
{title || ''}
</h3>
{content ? (
<p className="mt-2 text-sm leading-relaxed">{content}</p>
) : (
<div className="mt-2 text-sm text-muted-foreground min-h-[1.5rem]"></div>
<div className="text-muted-foreground mt-2 min-h-[1.5rem] text-sm"></div>
)}
</div>
</div>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
export function FeatureCardPlain({
title,
@@ -7,19 +7,18 @@ export function FeatureCardPlain({
title?: string;
content?: string;
}) {
return (
<div className="rounded-[18px] bg-primary/20 p-1">
<div className="h-full rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-background rounded-xl h-full flex flex-col">
<div className="p-5 flex-1 flex flex-col">
<h3 className="text-base sm:text-lg text-primary font-sans">
{title || ""}
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 h-full rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background flex h-full flex-col rounded-xl">
<div className="flex flex-1 flex-col p-5">
<h3 className="text-primary font-sans text-base sm:text-lg">
{title || ''}
</h3>
{content ? (
<p className="mt-2 text-sm leading-relaxed">{content}</p>
) : (
<div className="mt-2 text-sm text-muted-foreground min-h-[1.5rem]"></div>
<div className="text-muted-foreground mt-2 min-h-[1.5rem] text-sm"></div>
)}
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
const REPO = "usesend/usesend";
const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`;
const API_URL = `https://api.github.com/repos/${REPO}`;
const REVALIDATE_SECONDS = 60 * 60 * 24 * 7; // 7 days
@@ -8,15 +8,15 @@ const REVALIDATE_SECONDS = 60 * 60 * 24 * 7; // 7 days
function formatCompact(n: number): string {
if (n < 1000) return n.toLocaleString();
const units = [
{ v: 1_000_000_000, s: " B" },
{ v: 1_000_000, s: " M" },
{ v: 1_000, s: " K" },
{ v: 1_000_000_000, s: ' B' },
{ v: 1_000_000, s: ' M' },
{ v: 1_000, s: ' K' },
];
for (const u of units) {
if (n >= u.v) {
const num = n / u.v;
const rounded = Math.round(num * 10) / 10; // 1 decimal
const str = rounded.toFixed(1).replace(/\.0$/, "");
const str = rounded.toFixed(1).replace(/\.0$/, '');
return str + u.s;
}
}
@@ -25,9 +25,9 @@ function formatCompact(n: number): string {
export async function GitHubStarsButton() {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "usesend-marketing",
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'usesend-marketing',
};
if (process.env.GITHUB_TOKEN)
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
@@ -40,17 +40,17 @@ export async function GitHubStarsButton() {
});
if (res.ok) {
const json = (await res.json()) as { stargazers_count?: number };
if (typeof json.stargazers_count === "number")
if (typeof json.stargazers_count === 'number')
stars = json.stargazers_count;
}
} catch {
// ignore network errors; show placeholder
}
const formatted = stars == null ? "—" : formatCompact(stars);
const formatted = stars == null ? '—' : formatCompact(stars);
return (
<Button variant="outline" size="lg" className="px-4 gap-2">
<Button variant="outline" size="lg" className="gap-2 px-4">
<a
href={REPO_URL}
target="_blank"
@@ -60,7 +60,7 @@ export async function GitHubStarsButton() {
>
<GitHubIcon className="h-4 w-4" />
<span>GitHub</span>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-muted-foreground">
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{formatted}
</span>
</a>
@@ -68,7 +68,7 @@ export async function GitHubStarsButton() {
);
}
function GitHubIcon({ className = "" }: { className?: string }) {
function GitHubIcon({ className = '' }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import React from "react";
import React from 'react';
type SliderProps = {
label: string;
@@ -19,35 +19,35 @@ function Slider({
min = 0,
max = 100000,
step = 500,
suffix = "",
suffix = '',
}: SliderProps) {
const id = React.useId();
const [dragging, setDragging] = React.useState(false);
const percent = Math.max(
0,
Math.min(100, ((value - min) / (max - min)) * 100)
Math.min(100, ((value - min) / (max - min)) * 100),
);
React.useEffect(() => {
if (!dragging) return;
const stop = () => setDragging(false);
window.addEventListener("mouseup", stop);
window.addEventListener("touchend", stop);
window.addEventListener("pointerup", stop);
window.addEventListener('mouseup', stop);
window.addEventListener('touchend', stop);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener("mouseup", stop);
window.removeEventListener("touchend", stop);
window.removeEventListener("pointerup", stop);
window.removeEventListener('mouseup', stop);
window.removeEventListener('touchend', stop);
window.removeEventListener('pointerup', stop);
};
}, [dragging]);
return (
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<div className="w-full sm:w-56 md:w-72 shrink-0">
<label htmlFor={id} className="text-sm font-medium block">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="w-full shrink-0 sm:w-56 md:w-72">
<label htmlFor={id} className="block text-sm font-medium">
{label}
</label>
<div className="mt-1 text-xs sm:text-sm text-muted-foreground tabular-nums truncate">
<div className="text-muted-foreground mt-1 truncate text-xs tabular-nums sm:text-sm">
{value.toLocaleString()} {suffix}
</div>
</div>
@@ -63,7 +63,7 @@ function Slider({
onMouseDown={() => setDragging(true)}
onTouchStart={() => setDragging(true)}
onPointerDown={() => setDragging(true)}
className="w-full accent-primary"
className="accent-primary w-full"
aria-label={label}
aria-valuetext={`${value.toLocaleString()} ${suffix}`}
/>
@@ -72,7 +72,7 @@ function Slider({
className="pointer-events-none absolute -top-9 left-0 -translate-x-1/2"
style={{ left: `${percent}%` }}
>
<div className="rounded-md bg-foreground px-2 py-1 text-[11px] font-medium text-background tabular-nums shadow whitespace-nowrap">
<div className="bg-foreground text-background whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-medium tabular-nums shadow">
{value.toLocaleString()} {suffix}
</div>
</div>
@@ -98,15 +98,15 @@ export function PricingCalculator() {
const totalDue = Math.max(subtotal, MINIMUM_SPEND);
return (
<div className="rounded-[18px] bg-primary/20 p-1">
<div className="rounded-[14px] bg-primary/20 p-0.5 shadow-sm">
<div className="bg-primary/20 rounded-[18px] p-1">
<div className="bg-primary/20 rounded-[14px] p-0.5 shadow-sm">
<div className="bg-background rounded-xl p-5 pb-10">
<div className="flex flex-col gap-6">
<div className="text-center">
<div className="text-sm uppercase tracking-wider text-primary">
<div className="text-primary text-sm uppercase tracking-wider">
Pricing Calculator
</div>
<p className="mt-1 text-xs sm:text-sm text-muted-foreground">
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">
Drag the sliders to estimate your monthly cost.
</p>
</div>
@@ -132,38 +132,38 @@ export function PricingCalculator() {
/>
</div>
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-4 items-center">
<div className="rounded-lg border border-primary/30 p-4">
<div className="text-xs text-muted-foreground">Marketing</div>
<div className="mt-2 grid grid-cols-1 items-center gap-4 sm:grid-cols-3">
<div className="border-primary/30 rounded-lg border p-4">
<div className="text-muted-foreground text-xs">Marketing</div>
<div className="text-lg font-medium">
${marketingCost.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
@ ${MARKETING_RATE.toFixed(4)} each
</div>
</div>
<div className="rounded-lg border border-primary/30 p-4">
<div className="text-xs text-muted-foreground">
<div className="border-primary/30 rounded-lg border p-4">
<div className="text-muted-foreground text-xs">
Transactional
</div>
<div className="text-lg font-medium">
${transactionalCost.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
@ ${TRANSACTIONAL_RATE.toFixed(4)} each
</div>
</div>
<div className="rounded-lg border border-primary/30 p-4 bg-primary/10">
<div className="text-xs text-muted-foreground">
<div className="border-primary/30 bg-primary/10 rounded-lg border p-4">
<div className="text-muted-foreground text-xs">
Estimated Total
</div>
<div className="text-3xl text-primary font-semibold">
<div className="text-primary text-3xl font-semibold">
${totalDue.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
{subtotal < MINIMUM_SPEND
? "Minimum $10 applies"
: "before taxes"}
? 'Minimum $10 applies'
: 'before taxes'}
</div>
</div>
</div>

View File

@@ -1,16 +1,16 @@
import Image from "next/image";
import Link from "next/link";
import Image from 'next/image';
import Link from 'next/link';
// Replaced StatusBadge with external status badge image
const REPO = "usesend/usesend";
const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com";
const APP_URL = 'https://app.usesend.com';
export function SiteFooter() {
return (
<footer className="py-10 border-t border-border">
<footer className="border-border border-t py-10">
<div className="mx-auto max-w-6xl px-6">
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex items-center gap-2 sm:w-56">
<Image
src="/logo-squircle.png"
@@ -21,13 +21,13 @@ export function SiteFooter() {
<span className="text-primary font-mono">useSend</span>
</div>
<div className="sm:ml-auto flex items-start gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-x-12 gap-y-2 text-sm">
<div className="flex items-start gap-4 sm:ml-auto">
<div className="grid grid-cols-2 gap-x-12 gap-y-2 text-sm sm:grid-cols-3">
<div>
<div className="text-xs uppercase tracking-wider mb-2">
<div className="mb-2 text-xs uppercase tracking-wider">
Product
</div>
<ul className="space-y-2 text-muted-foreground">
<ul className="text-muted-foreground space-y-2">
<li>
<a
href={APP_URL}
@@ -62,10 +62,10 @@ export function SiteFooter() {
</div>
<div>
<div className="text-xs uppercase tracking-wider mb-2">
<div className="mb-2 text-xs uppercase tracking-wider">
Contact
</div>
<ul className="space-y-2 text-muted-foreground">
<ul className="text-muted-foreground space-y-2">
<li>
<a
href="mailto:hey@usesend.com"
@@ -118,10 +118,10 @@ export function SiteFooter() {
</div>
<div>
<div className="text-xs uppercase tracking-wider mb-2">
<div className="mb-2 text-xs uppercase tracking-wider">
Company
</div>
<ul className="space-y-2 text-muted-foreground">
<ul className="text-muted-foreground space-y-2">
<li>
<Link
href="/privacy"
@@ -160,7 +160,7 @@ export function SiteFooter() {
</div>
</div>
<div className="mt-6 text-xs text-muted-foreground mx-auto text-center">
<div className="text-muted-foreground mx-auto mt-6 text-center text-xs">
© {new Date().getFullYear()} useSend. All rights reserved.
</div>
</div>

View File

@@ -1,31 +1,38 @@
"use client";
'use client';
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { Button } from "@usesend/ui/src/button";
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { Button } from '@usesend/ui/src/button';
const REPO = "usesend/usesend";
const REPO = 'usesend/usesend';
const REPO_URL = `https://github.com/${REPO}`;
const APP_URL = "https://app.usesend.com";
const APP_URL = 'https://app.usesend.com';
export function TopNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const isHome = pathname === "/";
const pricingHref = isHome ? "#pricing" : "/#pricing";
const isHome = pathname === '/';
const pricingHref = isHome ? '#pricing' : '/#pricing';
return (
<header className="py-4 border-b border-border sticky top-0 z-20 backdrop-blur supports-[backdrop-filter]:bg-sidebar-background/80">
<div className="mx-auto max-w-6xl px-6 flex items-center justify-between gap-4 text-sm">
<Link href="/" className="flex items-center gap-2 group">
<Image src="/logo-squircle.png" alt="useSend" width={24} height={24} />
<span className="text-primary font-mono text-[16px] group-hover:opacity-90">useSend</span>
<header className="border-border supports-[backdrop-filter]:bg-sidebar-background/80 sticky top-0 z-20 border-b py-4 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-6 text-sm">
<Link href="/" className="group flex items-center gap-2">
<Image
src="/logo-squircle.png"
alt="useSend"
width={24}
height={24}
/>
<span className="text-primary font-mono text-[16px] group-hover:opacity-90">
useSend
</span>
</Link>
{/* Desktop nav */}
<nav className="hidden sm:flex items-center gap-4 text-muted-foreground">
<nav className="text-muted-foreground hidden items-center gap-4 sm:flex">
<Link href={pricingHref} className="hover:text-foreground">
Pricing
</Link>
@@ -55,14 +62,29 @@ export function TopNav() {
{/* Mobile hamburger */}
<button
aria-label="Open menu"
className="sm:hidden inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-border"
className="text-muted-foreground hover:text-foreground hover:bg-accent focus:ring-border inline-flex items-center justify-center rounded-md p-2 focus:outline-none focus:ring-2 sm:hidden"
onClick={() => setOpen((v) => !v)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-6 w-6">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="h-6 w-6"
>
{open ? (
<path d="M6 18 18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M6 18 18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
/>
) : (
<path d="M3 6h18M3 12h18M3 18h18" strokeLinecap="round" strokeLinejoin="round" />
<path
d="M3 6h18M3 12h18M3 18h18"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
</svg>
</button>
@@ -70,16 +92,20 @@ export function TopNav() {
{/* Mobile menu panel */}
{open ? (
<div className="sm:hidden border-t border-border bg-sidebar-background/95 backdrop-blur">
<div className="mx-auto max-w-6xl px-6 py-3 flex flex-col gap-2">
<Link href={pricingHref} className="py-2 text-muted-foreground hover:text-foreground" onClick={() => setOpen(false)}>
<div className="border-border bg-sidebar-background/95 border-t backdrop-blur sm:hidden">
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-6 py-3">
<Link
href={pricingHref}
className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)}
>
Pricing
</Link>
<a
href="https://docs.usesend.com"
target="_blank"
rel="noopener noreferrer"
className="py-2 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)}
>
Docs
@@ -88,14 +114,19 @@ export function TopNav() {
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="py-2 text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground py-2"
onClick={() => setOpen(false)}
>
GitHub
</a>
<div className="pt-2">
<Button className="w-full">
<a href={APP_URL} target="_blank" rel="noopener noreferrer" onClick={() => setOpen(false)}>
<a
href={APP_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
>
Get started
</a>
</Button>

View File

@@ -0,0 +1,36 @@
import type { MDXComponents } from 'mdx/types';
const components = {
h1: ({ children }) => (
<h1 className="text-primary font-sans text-3xl font-semibold tracking-wide">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-primary font-sans text-xl font-semibold tracking-wide">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="font-sans text-lg font-medium tracking-wide">{children}</h3>
),
p: ({ children }) => (
<p className="font-sans text-base font-normal leading-relaxed tracking-wide">
{children}
</p>
),
ul: ({ children }) => (
<ul className="list-inside list-disc space-y-1 pl-4 font-sans">
{children}
</ul>
),
a: ({ children, href }) => (
<a href={href} className="text-primary-light">
{children}
</a>
),
} satisfies MDXComponents;
export function useMDXComponents(): MDXComponents {
return components;
}

View File

@@ -1,13 +1,12 @@
import { type Config } from "tailwindcss";
import sharedConfig from "@usesend/tailwind-config/tailwind.config";
import path from "path";
import { type Config } from 'tailwindcss';
import sharedConfig from '@usesend/tailwind-config/tailwind.config';
import path from 'path';
export default {
...sharedConfig,
content: [
"./src/**/*.tsx",
`${path.join(require.resolve("@usesend/ui"), "..")}/**/*.{ts,tsx}`,
`${path.join(require.resolve("@usesend/email-editor"), "..")}/**/*.{ts,tsx}`,
'./src/**/*.tsx',
`${path.join(require.resolve('@usesend/ui'), '..')}/**/*.{ts,tsx}`,
`${path.join(require.resolve('@usesend/email-editor'), '..')}/**/*.{ts,tsx}`,
],
} satisfies Config;

View File

@@ -1,16 +1,16 @@
import { SMTPServer, SMTPServerOptions, SMTPServerSession } from "smtp-server";
import { Readable } from "stream";
import dotenv from "dotenv";
import { simpleParser } from "mailparser";
import { readFileSync, watch, FSWatcher } from "fs";
import { SMTPServer, SMTPServerOptions, SMTPServerSession } from 'smtp-server';
import { Readable } from 'stream';
import dotenv from 'dotenv';
import { simpleParser } from 'mailparser';
import { readFileSync, watch, FSWatcher } from 'fs';
dotenv.config();
const AUTH_USERNAME = process.env.SMTP_AUTH_USERNAME ?? "usesend";
const AUTH_USERNAME = process.env.SMTP_AUTH_USERNAME ?? 'usesend';
const BASE_URL =
process.env.USESEND_BASE_URL ??
process.env.UNSEND_BASE_URL ??
"https://app.usesend.com";
'https://app.usesend.com';
const SSL_KEY_PATH =
process.env.USESEND_API_KEY_PATH ?? process.env.UNSEND_API_KEY_PATH;
const SSL_CERT_PATH =
@@ -18,17 +18,17 @@ const SSL_CERT_PATH =
async function sendEmailToUseSend(emailData: any, apiKey: string) {
try {
const apiEndpoint = "/api/v1/emails";
const apiEndpoint = '/api/v1/emails';
const url = new URL(apiEndpoint, BASE_URL); // Combine base URL with endpoint
console.log("Sending email to useSend API at:", url.href); // Debug statement
console.log('Sending email to useSend API at:', url.href); // Debug statement
const emailDataText = JSON.stringify(emailData);
const response = await fetch(url.href, {
method: "POST",
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: emailDataText,
});
@@ -36,24 +36,24 @@ async function sendEmailToUseSend(emailData: any, apiKey: string) {
if (!response.ok) {
const errorData = await response.text();
console.error(
"useSend API error response: error:",
'useSend API error response: error:',
JSON.stringify(errorData, null, 4),
`\nemail data: ${emailDataText}`,
);
throw new Error(
`Failed to send email: ${errorData || "Unknown error from server"}`,
`Failed to send email: ${errorData || 'Unknown error from server'}`,
);
}
const responseData = await response.json();
console.log("useSend API response:", responseData);
console.log('useSend API response:', responseData);
} catch (error) {
if (error instanceof Error) {
console.error("Error message:", error.message);
console.error('Error message:', error.message);
throw new Error(`Failed to send email: ${error.message}`);
} else {
console.error("Unexpected error:", error);
throw new Error("Failed to send email: Unexpected error occurred");
console.error('Unexpected error:', error);
throw new Error('Failed to send email: Unexpected error occurred');
}
}
}
@@ -76,24 +76,24 @@ const serverOptions: SMTPServerOptions = {
session: SMTPServerSession,
callback: (error?: Error) => void,
) {
console.log("Receiving email data..."); // Debug statement
console.log('Receiving email data...'); // Debug statement
simpleParser(stream, (err, parsed) => {
if (err) {
console.error("Failed to parse email data:", err.message);
console.error('Failed to parse email data:', err.message);
return callback(err);
}
if (!session.user) {
console.error("No API key found in session");
return callback(new Error("No API key found in session"));
console.error('No API key found in session');
return callback(new Error('No API key found in session'));
}
const emailObject = {
to: Array.isArray(parsed.to)
? parsed.to.map((addr) => addr.text).join(", ")
? parsed.to.map((addr) => addr.text).join(', ')
: parsed.to?.text,
from: Array.isArray(parsed.from)
? parsed.from.map((addr) => addr.text).join(", ")
? parsed.from.map((addr) => addr.text).join(', ')
: parsed.from?.text,
subject: parsed.subject,
text: parsed.text,
@@ -103,20 +103,20 @@ const serverOptions: SMTPServerOptions = {
sendEmailToUseSend(emailObject, session.user)
.then(() => callback())
.then(() => console.log("Email sent successfully to: ", emailObject.to))
.then(() => console.log('Email sent successfully to: ', emailObject.to))
.catch((error) => {
console.error("Failed to send email:", error.message);
console.error('Failed to send email:', error.message);
callback(error);
});
});
},
onAuth(auth, session: any, callback: (error?: Error, user?: any) => void) {
if (auth.username === AUTH_USERNAME && auth.password) {
console.log("Authenticated successfully"); // Debug statement
console.log('Authenticated successfully'); // Debug statement
callback(undefined, { user: auth.password });
} else {
console.error("Invalid username or password");
callback(new Error("Invalid username or password"));
console.error('Invalid username or password');
callback(new Error('Invalid username or password'));
}
},
size: 10485760,
@@ -137,7 +137,7 @@ function startServers() {
);
});
server.on("error", (err) => {
server.on('error', (err) => {
console.error(`Error occurred on port ${port}:`, err);
});
@@ -153,7 +153,7 @@ function startServers() {
console.log(`STARTTLS SMTP server is listening on port ${port}`);
});
server.on("error", (err) => {
server.on('error', (err) => {
console.error(`Error occurred on port ${port}:`, err);
});
@@ -166,10 +166,10 @@ function startServers() {
const { key, cert } = loadCertificates();
if (key && cert) {
servers.forEach((srv) => srv.updateSecureContext({ key, cert }));
console.log("TLS certificates reloaded");
console.log('TLS certificates reloaded');
}
} catch (err) {
console.error("Failed to reload TLS certificates", err);
console.error('Failed to reload TLS certificates', err);
}
};
@@ -183,12 +183,12 @@ function startServers() {
const { servers, watchers } = startServers();
function shutdown() {
console.log("Shutting down SMTP server...");
console.log('Shutting down SMTP server...');
watchers.forEach((w) => w.close());
servers.forEach((s) => s.close());
process.exit(0);
}
["SIGINT", "SIGTERM", "SIGQUIT"].forEach((signal) => {
['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => {
process.on(signal, shutdown);
});

View File

@@ -1,10 +1,10 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { defineConfig, Options } from "tsup";
import { defineConfig, Options } from 'tsup';
// eslint-disable-next-line import/no-default-export
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs"],
entry: ['src/server.ts'],
format: ['cjs'],
dts: true,
minify: true,
clean: true,

View File

@@ -1,17 +1,17 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { Plus } from "lucide-react";
import { useState } from "react";
import { AddSesSettingsForm } from "~/components/settings/AddSesSettings";
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { AddSesSettingsForm } from '~/components/settings/AddSesSettings';
export default function AddSesConfiguration() {
const [open, setOpen] = useState(false);
@@ -23,7 +23,7 @@ export default function AddSesConfiguration() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add SES configuration
</Button>
</DialogTrigger>

View File

@@ -1,17 +1,17 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { Edit } from "lucide-react";
import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Edit } from 'lucide-react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -20,14 +20,14 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { api } from "~/trpc/react";
import { Input } from "@usesend/ui/src/input";
import { toast } from "@usesend/ui/src/toaster";
import Spinner from "@usesend/ui/src/spinner";
import { SesSetting } from "@prisma/client";
} from '@usesend/ui/src/form';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { api } from '~/trpc/react';
import { Input } from '@usesend/ui/src/input';
import { toast } from '@usesend/ui/src/toaster';
import Spinner from '@usesend/ui/src/spinner';
import { SesSetting } from '@prisma/client';
const FormSchema = z.object({
settingsId: z.string(),
@@ -96,7 +96,7 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
onSuccess?.();
},
onError: (e) => {
toast.error("Failed to update", {
toast.error('Failed to update', {
description: e.message,
});
},
@@ -107,7 +107,7 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className=" flex flex-col gap-8 w-full"
className="flex w-full flex-col gap-8"
>
<FormField
control={form.control}
@@ -151,12 +151,12 @@ export const EditSesSettingsForm: React.FC<SesSettingsProps> = ({
<Button
type="submit"
disabled={updateSesSettings.isPending}
className="w-[200px] mx-auto"
className="mx-auto w-[200px]"
>
{updateSesSettings.isPending ? (
<Spinner className="w-5 h-5" />
<Spinner className="h-5 w-5" />
) : (
"Update"
'Update'
)}
</Button>
</form>

View File

@@ -0,0 +1,4 @@
export const timeframeOptions = [
{ label: 'Today', value: 'today' },
{ label: 'This month', value: 'thisMonth' },
] as const;

View File

@@ -0,0 +1,186 @@
'use client';
import { useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@usesend/ui/src/card';
import { Label } from '@usesend/ui/src/label';
import { Switch } from '@usesend/ui/src/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@usesend/ui/src/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@usesend/ui/src/table';
import Spinner from '@usesend/ui/src/spinner';
import { api } from '~/trpc/react';
import { isCloud } from '~/utils/common';
import { timeframeOptions } from './constants';
import { keepPreviousData } from '@tanstack/react-query';
export default function AdminEmailAnalyticsPage() {
const isCloudEnv = isCloud();
const [timeframe, setTimeframe] =
useState<(typeof timeframeOptions)[number]['value']>('today');
const [paidOnly, setPaidOnly] = useState(false);
const analyticsQuery = api.admin.getEmailAnalytics.useQuery(
{
timeframe,
paidOnly,
},
{ enabled: isCloudEnv, placeholderData: keepPreviousData },
);
const data = analyticsQuery.data;
const totals = data?.totals ?? {
sent: 0,
delivered: 0,
opened: 0,
clicked: 0,
bounced: 0,
complained: 0,
hardBounced: 0,
};
const rows = useMemo(() => data?.rows ?? [], [data]);
if (!isCloudEnv) {
return (
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
Email analytics are available only in the cloud deployment.
</div>
);
}
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold">Email analytics</h2>
<div className="flex flex-wrap gap-4">
<div className="w-48">
<Label htmlFor="timeframe">Timeframe</Label>
<Select
value={timeframe}
onValueChange={(value) =>
setTimeframe(value as (typeof timeframeOptions)[number]['value'])
}
>
<SelectTrigger id="timeframe">
<SelectValue placeholder="Select timeframe" />
</SelectTrigger>
<SelectContent>
{timeframeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-3">
<Switch checked={paidOnly} onCheckedChange={setPaidOnly} id="paid" />
<Label htmlFor="paid">Paid users only</Label>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<SummaryCard label="Sent" value={totals.sent} />
<SummaryCard label="Delivered" value={totals.delivered} />
<SummaryCard label="Opened" value={totals.opened} />
<SummaryCard label="Clicked" value={totals.clicked} />
<SummaryCard label="Bounced" value={totals.bounced} />
<SummaryCard label="Complained" value={totals.complained} />
<SummaryCard label="Hard bounced" value={totals.hardBounced} />
</div>
<Card className="overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Usage by team</CardTitle>
{data ? (
<p className="text-muted-foreground text-sm">
Since {data.timeframe === 'today' ? 'today' : data.periodStart}
</p>
) : null}
</div>
{analyticsQuery.isLoading ? <Spinner className="h-4 w-4" /> : null}
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Team</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Sent</TableHead>
<TableHead className="text-right">Delivered</TableHead>
<TableHead className="text-right">Opened</TableHead>
<TableHead className="text-right">Clicked</TableHead>
<TableHead className="text-right">Bounced</TableHead>
<TableHead className="text-right">Complained</TableHead>
<TableHead className="text-right">Hard bounced</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analyticsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={9} className="py-12 text-center">
<Spinner className="h-6 w-6" />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="py-12 text-center">
No email activity found for this period.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.teamId}>
<TableCell>{row.name}</TableCell>
<TableCell>{row.plan}</TableCell>
<TableCell className="text-right">{row.sent}</TableCell>
<TableCell className="text-right">
{row.delivered}
</TableCell>
<TableCell className="text-right">{row.opened}</TableCell>
<TableCell className="text-right">{row.clicked}</TableCell>
<TableCell className="text-right">{row.bounced}</TableCell>
<TableCell className="text-right">
{row.complained}
</TableCell>
<TableCell className="text-right">
{row.hardBounced}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
function SummaryCard({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-muted-foreground text-sm font-medium">
{label}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{value.toLocaleString()}</p>
</CardContent>
</Card>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
import { isCloud } from "~/utils/common";
import { SettingsNavButton } from '../dev-settings/settings-nav-button';
import { isCloud } from '~/utils/common';
export default function AdminLayout({
children,
@@ -12,18 +12,17 @@ export default function AdminLayout({
<div>
<h1 className="text-lg font-bold">Admin</h1>
<div className="mt-4 flex gap-4">
<SettingsNavButton href="/admin">
SES Configurations
</SettingsNavButton>
<SettingsNavButton href="/admin">SES Configurations</SettingsNavButton>
{isCloud() ? (
<SettingsNavButton href="/admin/teams">
Teams
<SettingsNavButton href="/admin/teams">Teams</SettingsNavButton>
) : null}
{isCloud() ? (
<SettingsNavButton href="/admin/email-analytics">
Email analytics
</SettingsNavButton>
) : null}
{isCloud() ? (
<SettingsNavButton href="/admin/waitlist">
Waitlist
</SettingsNavButton>
<SettingsNavButton href="/admin/waitlist">Waitlist</SettingsNavButton>
) : null}
</div>
<div className="mt-8">{children}</div>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import AddSesConfiguration from "./add-ses-configuration";
import SesConfigurations from "./ses-configurations";
import AddSesConfiguration from './add-ses-configuration';
import SesConfigurations from './ses-configurations';
export default function AdminSesPage() {
return (

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,22 +7,22 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import Spinner from "@usesend/ui/src/spinner";
import EditSesConfiguration from "./edit-ses-configuration";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
} from '@usesend/ui/src/table';
import { formatDistanceToNow } from 'date-fns';
import { api } from '~/trpc/react';
import Spinner from '@usesend/ui/src/spinner';
import EditSesConfiguration from './edit-ses-configuration';
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
export default function SesConfigurations() {
const sesSettingsQuery = api.admin.getSesSettings.useQuery();
return (
<div className="">
<div className="border rounded-xl shadow">
<div className="rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Region</TableHead>
<TableHead>Prefix Key</TableHead>
<TableHead>Callback URL</TableHead>
@@ -36,16 +36,16 @@ export default function SesConfigurations() {
<TableBody>
{sesSettingsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<TableCell colSpan={6} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : sesSettingsQuery.data?.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={6} className="text-center py-4">
<TableCell colSpan={6} className="py-4 text-center">
<p>No SES configurations added</p>
</TableCell>
</TableRow>
@@ -63,7 +63,7 @@ export default function SesConfigurations() {
</div>
</TableCell>
<TableCell>
{sesSetting.callbackSuccess ? "Success" : "Failed"}
{sesSetting.callbackSuccess ? 'Success' : 'Failed'}
</TableCell>
<TableCell>
{formatDistanceToNow(sesSetting.createdAt)} ago

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import { useEffect, useState } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@usesend/ui/src/button";
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@usesend/ui/src/button';
import {
Form,
FormControl,
@@ -12,43 +12,45 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Input } from "@usesend/ui/src/input";
import { Switch } from "@usesend/ui/src/switch";
import Spinner from "@usesend/ui/src/spinner";
import { toast } from "@usesend/ui/src/toaster";
import { Badge } from "@usesend/ui/src/badge";
} from '@usesend/ui/src/form';
import { Input } from '@usesend/ui/src/input';
import { Switch } from '@usesend/ui/src/switch';
import Spinner from '@usesend/ui/src/spinner';
import { toast } from '@usesend/ui/src/toaster';
import { Badge } from '@usesend/ui/src/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { formatDistanceToNow } from "date-fns";
} from '@usesend/ui/src/select';
import { formatDistanceToNow } from 'date-fns';
import { api } from "~/trpc/react";
import type { AppRouter } from "~/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
import { isCloud } from "~/utils/common";
import { api } from '~/trpc/react';
import type { AppRouter } from '~/server/api/root';
import type { inferRouterOutputs } from '@trpc/server';
import { isCloud } from '~/utils/common';
const searchSchema = z.object({
query: z
.string({ required_error: "Enter a team ID, name, domain, or member email" })
.string({
required_error: 'Enter a team ID, name, domain, or member email',
})
.trim()
.min(1, "Enter a team ID, name, domain, or member email"),
.min(1, 'Enter a team ID, name, domain, or member email'),
});
type SearchInput = z.infer<typeof searchSchema>;
type RouterOutputs = inferRouterOutputs<AppRouter>;
type TeamAdmin = NonNullable<RouterOutputs["admin"]["findTeam"]>;
type TeamAdmin = NonNullable<RouterOutputs['admin']['findTeam']>;
const updateSchema = z.object({
apiRateLimit: z.coerce.number().int().min(1).max(10_000),
dailyEmailLimit: z.coerce.number().int().min(0).max(10_000_000),
isBlocked: z.boolean(),
plan: z.enum(["FREE", "BASIC"]),
plan: z.enum(['FREE', 'BASIC']),
});
type UpdateInput = z.infer<typeof updateSchema>;
@@ -59,7 +61,7 @@ export default function AdminTeamsPage() {
const searchForm = useForm<SearchInput>({
resolver: zodResolver(searchSchema),
defaultValues: { query: "" },
defaultValues: { query: '' },
});
const updateForm = useForm<UpdateInput>({
@@ -68,7 +70,7 @@ export default function AdminTeamsPage() {
apiRateLimit: 1,
dailyEmailLimit: 0,
isBlocked: false,
plan: "FREE",
plan: 'FREE',
},
});
@@ -85,7 +87,7 @@ export default function AdminTeamsPage() {
if (!isCloud()) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
Team administration tools are available only in the cloud deployment.
</div>
);
@@ -96,13 +98,13 @@ export default function AdminTeamsPage() {
setHasSearched(true);
if (!data) {
setTeam(null);
toast.info("No team found for that query");
toast.info('No team found for that query');
return;
}
setTeam(data);
},
onError: (error) => {
toast.error(error.message ?? "Unable to search for team");
toast.error(error.message ?? 'Unable to search for team');
},
});
@@ -115,10 +117,10 @@ export default function AdminTeamsPage() {
isBlocked: updated.isBlocked,
plan: updated.plan,
});
toast.success("Team settings updated");
toast.success('Team settings updated');
},
onError: (error) => {
toast.error(error.message ?? "Unable to update team settings");
toast.error(error.message ?? 'Unable to update team settings');
},
});
@@ -166,7 +168,7 @@ export default function AdminTeamsPage() {
<Spinner className="mr-2 h-4 w-4" /> Searching...
</>
) : (
"Lookup team"
'Lookup team'
)}
</Button>
</form>
@@ -174,7 +176,7 @@ export default function AdminTeamsPage() {
</div>
{findTeam.isPending ? null : hasSearched && !team ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
No team matched that query. Try another search.
</div>
) : null}
@@ -183,75 +185,97 @@ export default function AdminTeamsPage() {
<div className="space-y-6 rounded-lg border p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">Team</p>
<p className="text-muted-foreground text-sm">Team</p>
<p className="text-xl font-semibold">{team.name}</p>
<p className="text-xs text-muted-foreground">
ID #{team.id} Created {formatDistanceToNow(new Date(team.createdAt), { addSuffix: true })}
<p className="text-muted-foreground text-xs">
ID #{team.id} Created{' '}
{formatDistanceToNow(new Date(team.createdAt), {
addSuffix: true,
})}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">Plan: {team.plan}</Badge>
<Badge variant={team.isBlocked ? "destructive" : "outline"}>
{team.isBlocked ? "Blocked" : "Active"}
<Badge variant={team.isBlocked ? 'destructive' : 'outline'}>
{team.isBlocked ? 'Blocked' : 'Active'}
</Badge>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Members</h3>
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
<h3 className="text-muted-foreground text-sm font-medium">
Members
</h3>
<div className="bg-muted/20 space-y-2 rounded-lg border p-3">
{team.teamUsers.length ? (
team.teamUsers.map((member) => (
<div
key={member.user.id}
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
className="bg-background flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<div>
<p className="font-medium">{member.user.name ?? member.user.email}</p>
<p className="text-xs text-muted-foreground">{member.user.email}</p>
<p className="font-medium">
{member.user.name ?? member.user.email}
</p>
<p className="text-muted-foreground text-xs">
{member.user.email}
</p>
</div>
<Badge variant="outline">{member.role}</Badge>
</div>
))
) : (
<p className="text-xs text-muted-foreground">No members found.</p>
<p className="text-muted-foreground text-xs">
No members found.
</p>
)}
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Domains</h3>
<div className="space-y-2 rounded-lg border bg-muted/20 p-3">
<h3 className="text-muted-foreground text-sm font-medium">
Domains
</h3>
<div className="bg-muted/20 space-y-2 rounded-lg border p-3">
{team.domains.length ? (
team.domains.map((domain) => (
<div
key={domain.id}
className="flex items-center justify-between rounded-md bg-background px-3 py-2 text-sm"
className="bg-background flex items-center justify-between rounded-md px-3 py-2 text-sm"
>
<span>{domain.name}</span>
<Badge variant={domain.status === "SUCCESS" ? "outline" : "secondary"}>
{domain.status === "SUCCESS"
? "Verified"
<Badge
variant={
domain.status === 'SUCCESS' ? 'outline' : 'secondary'
}
>
{domain.status === 'SUCCESS'
? 'Verified'
: domain.status.toLowerCase()}
</Badge>
</div>
))
) : (
<p className="text-xs text-muted-foreground">No domains connected.</p>
<p className="text-muted-foreground text-xs">
No domains connected.
</p>
)}
</div>
</div>
</div>
<div className="rounded-lg border bg-muted/10 p-4">
<p className="text-sm text-muted-foreground">
Billing contact: {team.billingEmail ?? "Not set"}
<div className="bg-muted/10 rounded-lg border p-4">
<p className="text-muted-foreground text-sm">
Billing contact: {team.billingEmail ?? 'Not set'}
</p>
</div>
<div className="rounded-lg border p-6">
<Form {...updateForm}>
<form onSubmit={updateForm.handleSubmit(onUpdateSubmit)} className="grid gap-6 lg:grid-cols-2">
<form
onSubmit={updateForm.handleSubmit(onUpdateSubmit)}
className="grid gap-6 lg:grid-cols-2"
>
<FormField
control={updateForm.control}
name="apiRateLimit"
@@ -336,8 +360,8 @@ export default function AdminTeamsPage() {
onCheckedChange={field.onChange}
disabled={updateTeam.isPending}
/>
<span className="text-sm text-muted-foreground">
{field.value ? "Team is blocked" : "Team is active"}
<span className="text-muted-foreground text-sm">
{field.value ? 'Team is blocked' : 'Team is active'}
</span>
</div>
</FormControl>
@@ -345,14 +369,14 @@ export default function AdminTeamsPage() {
</FormItem>
)}
/>
<div className="lg:col-span-2 flex justify-end">
<div className="flex justify-end lg:col-span-2">
<Button type="submit" disabled={updateTeam.isPending}>
{updateTeam.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" /> Saving...
</>
) : (
"Update team"
'Update team'
)}
</Button>
</div>

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import { useState } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@usesend/ui/src/button";
import { useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@usesend/ui/src/button';
import {
Form,
FormControl,
@@ -12,30 +12,30 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Input } from "@usesend/ui/src/input";
import Spinner from "@usesend/ui/src/spinner";
import { toast } from "@usesend/ui/src/toaster";
import { Switch } from "@usesend/ui/src/switch";
import { Badge } from "@usesend/ui/src/badge";
import { formatDistanceToNow } from "date-fns";
} from '@usesend/ui/src/form';
import { Input } from '@usesend/ui/src/input';
import Spinner from '@usesend/ui/src/spinner';
import { toast } from '@usesend/ui/src/toaster';
import { Switch } from '@usesend/ui/src/switch';
import { Badge } from '@usesend/ui/src/badge';
import { formatDistanceToNow } from 'date-fns';
import { api } from "~/trpc/react";
import { isCloud } from "~/utils/common";
import type { AppRouter } from "~/server/api/root";
import type { inferRouterOutputs } from "@trpc/server";
import { api } from '~/trpc/react';
import { isCloud } from '~/utils/common';
import type { AppRouter } from '~/server/api/root';
import type { inferRouterOutputs } from '@trpc/server';
const searchSchema = z.object({
email: z
.string({ required_error: "Email is required" })
.string({ required_error: 'Email is required' })
.trim()
.email("Enter a valid email address"),
.email('Enter a valid email address'),
});
type SearchInput = z.infer<typeof searchSchema>;
type RouterOutputs = inferRouterOutputs<AppRouter>;
type WaitlistUser = NonNullable<RouterOutputs["admin"]["findUserByEmail"]>;
type WaitlistUser = NonNullable<RouterOutputs['admin']['findUserByEmail']>;
export default function AdminWaitlistPage() {
const [userResult, setUserResult] = useState<WaitlistUser | null>(null);
@@ -44,7 +44,7 @@ export default function AdminWaitlistPage() {
const form = useForm<SearchInput>({
resolver: zodResolver(searchSchema),
defaultValues: {
email: "",
email: '',
},
});
@@ -53,14 +53,14 @@ export default function AdminWaitlistPage() {
setHasSearched(true);
if (!data) {
setUserResult(null);
toast.info("No user found for that email");
toast.info('No user found for that email');
return;
}
setUserResult(data);
},
onError: (error) => {
toast.error(error.message ?? "Unable to search for user");
toast.error(error.message ?? 'Unable to search for user');
},
});
@@ -69,12 +69,12 @@ export default function AdminWaitlistPage() {
setUserResult(updated);
toast.success(
updated.isWaitlisted
? "User marked as waitlisted"
: "User removed from waitlist",
? 'User marked as waitlisted'
: 'User removed from waitlist',
);
},
onError: (error) => {
toast.error(error.message ?? "Unable to update waitlist flag");
toast.error(error.message ?? 'Unable to update waitlist flag');
},
});
@@ -91,7 +91,7 @@ export default function AdminWaitlistPage() {
if (!isCloud()) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
<div className="bg-muted/30 text-muted-foreground rounded-lg border p-6 text-sm">
Waitlist tooling is available only in the cloud deployment.
</div>
);
@@ -101,7 +101,11 @@ export default function AdminWaitlistPage() {
<div className="space-y-6">
<div className="rounded-lg border p-6 shadow-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4" noValidate>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
noValidate
>
<FormField
control={form.control}
name="email"
@@ -126,7 +130,7 @@ export default function AdminWaitlistPage() {
<Spinner className="mr-2 h-4 w-4" /> Searching...
</>
) : (
"Lookup user"
'Lookup user'
)}
</Button>
</form>
@@ -134,7 +138,7 @@ export default function AdminWaitlistPage() {
</div>
{findUser.isPending ? null : hasSearched && !userResult ? (
<div className="rounded-lg border border-dashed p-6 text-sm text-muted-foreground">
<div className="text-muted-foreground rounded-lg border border-dashed p-6 text-sm">
No user matched that email. Try another search.
</div>
) : null}
@@ -143,18 +147,20 @@ export default function AdminWaitlistPage() {
<div className="space-y-4 rounded-lg border p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="text-muted-foreground text-sm">Email</p>
<p className="text-base font-medium">{userResult.email}</p>
</div>
<Badge variant={userResult.isWaitlisted ? "destructive" : "outline"}>
{userResult.isWaitlisted ? "Waitlisted" : "Active"}
<Badge
variant={userResult.isWaitlisted ? 'destructive' : 'outline'}
>
{userResult.isWaitlisted ? 'Waitlisted' : 'Active'}
</Badge>
</div>
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<p className="text-muted-foreground">Name</p>
<p>{userResult.name ?? "—"}</p>
<p>{userResult.name ?? '—'}</p>
</div>
<div>
<p className="text-muted-foreground">Joined</p>
@@ -169,7 +175,7 @@ export default function AdminWaitlistPage() {
<div className="flex flex-wrap items-center justify-between gap-4 border-t pt-4">
<div>
<p className="text-sm font-medium">Waitlist access</p>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
Toggle to control whether the user remains on the waitlist.
</p>
</div>

View File

@@ -1,18 +1,18 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Spinner } from "@usesend/ui/src/spinner";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Editor } from "@usesend/email-editor";
import { use, useState } from "react";
import { Campaign } from "@prisma/client";
import { api } from '~/trpc/react';
import { Spinner } from '@usesend/ui/src/spinner';
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import { Editor } from '@usesend/email-editor';
import { use, useState } from 'react';
import { Campaign } from '@prisma/client';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
import {
Dialog,
DialogContent,
@@ -20,10 +20,10 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -31,16 +31,16 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { toast } from "@usesend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns";
} from '@usesend/ui/src/form';
import { toast } from '@usesend/ui/src/toaster';
import { useDebouncedCallback } from 'use-debounce';
import { formatDistanceToNow } from 'date-fns';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@usesend/ui/src/accordion";
} from '@usesend/ui/src/accordion';
const sendSchema = z.object({
confirmation: z.string(),
@@ -68,15 +68,15 @@ export default function EditCampaignPage({
if (isLoading) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-6 h-6" />
<div className="flex h-full items-center justify-center">
<Spinner className="h-6 w-6" />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="flex h-full items-center justify-center">
<p className="text-red-500">Failed to load campaign</p>
</div>
);
@@ -140,9 +140,9 @@ function CampaignEditor({
async function onSendCampaign(values: z.infer<typeof sendSchema>) {
if (
values.confirmation?.toLocaleLowerCase() !== "Send".toLocaleLowerCase()
values.confirmation?.toLocaleLowerCase() !== 'Send'.toLocaleLowerCase()
) {
sendForm.setError("confirmation", {
sendForm.setError('confirmation', {
message: "Please type 'Send' to confirm",
});
return;
@@ -171,7 +171,7 @@ function CampaignEditor({
);
}
console.log("file type: ", file.type);
console.log('file type: ', file.type);
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
name: file.name,
@@ -180,32 +180,32 @@ function CampaignEditor({
});
const response = await fetch(uploadUrl, {
method: "PUT",
method: 'PUT',
body: file,
});
if (!response.ok) {
throw new Error("Failed to upload file");
throw new Error('Failed to upload file');
}
return imageUrl;
};
const confirmation = sendForm.watch("confirmation");
const confirmation = sendForm.watch('confirmation');
const contactBook = contactBooksQuery.data?.find(
(book) => book.id === contactBookId,
);
return (
<div className="p-4 container mx-auto ">
<div className="container mx-auto p-4">
<div className="mx-auto">
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
<div className="mx-auto mb-4 flex w-[700px] items-center justify-between">
<Input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
className="w-[300px] border-0 px-0.5 focus:outline-none focus:ring-0"
onBlur={() => {
if (name === campaign.name || !name) {
return;
@@ -227,12 +227,12 @@ function CampaignEditor({
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
{isSaving ? (
<div className="h-2 w-2 bg-yellow-500 rounded-full" />
<div className="h-2 w-2 rounded-full bg-yellow-500" />
) : (
<div className="h-2 w-2 bg-emerald-500 rounded-full" />
<div className="h-2 w-2 rounded-full bg-emerald-500" />
)}
{formatDistanceToNow(campaign.updatedAt) === "less than a minute"
? "just now"
{formatDistanceToNow(campaign.updatedAt) === 'less than a minute'
? 'just now'
: `${formatDistanceToNow(campaign.updatedAt)} ago`}
</div>
<Dialog open={openSendDialog} onOpenChange={setOpenSendDialog}>
@@ -272,12 +272,12 @@ function CampaignEditor({
disabled={
sendCampaignMutation.isPending ||
confirmation?.toLocaleLowerCase() !==
"Send".toLocaleLowerCase()
'Send'.toLocaleLowerCase()
}
>
{sendCampaignMutation.isPending
? "Sending..."
: "Send"}
? 'Sending...'
: 'Send'}
</Button>
</div>
</form>
@@ -290,9 +290,9 @@ function CampaignEditor({
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<div className="flex flex-col border shadow rounded-lg mt-12 mb-12 p-4 w-[700px] mx-auto z-50">
<div className="z-50 mx-auto mb-12 mt-12 flex w-[700px] flex-col rounded-lg border p-4 shadow">
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
<label className="text-muted-foreground block w-[80px] text-sm">
Subject
</label>
<input
@@ -318,14 +318,14 @@ function CampaignEditor({
},
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
/>
<AccordionTrigger className="py-0"></AccordionTrigger>
</div>
<AccordionContent className=" flex flex-col gap-4">
<div className=" flex items-center gap-4 mt-4">
<label className=" text-sm w-[80px] text-muted-foreground">
<AccordionContent className="flex flex-col gap-4">
<div className="mt-4 flex items-center gap-4">
<label className="text-muted-foreground w-[80px] text-sm">
From
</label>
<input
@@ -334,7 +334,7 @@ function CampaignEditor({
onChange={(e) => {
setFrom(e.target.value);
}}
className="mt-1 py-1 w-full text-sm outline-none border-b border-transparent focus:border-border bg-transparent"
className="focus:border-border mt-1 w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
placeholder="Friendly name<hello@example.com>"
onBlur={() => {
if (from === campaign.from || !from) {
@@ -356,7 +356,7 @@ function CampaignEditor({
/>
</div>
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
<label className="text-muted-foreground block w-[80px] text-sm">
Reply To
</label>
<input
@@ -365,7 +365,7 @@ function CampaignEditor({
onChange={(e) => {
setReplyTo(e.target.value);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
placeholder="hello@example.com"
onBlur={() => {
if (replyTo === campaign.replyTo[0]) {
@@ -388,7 +388,7 @@ function CampaignEditor({
</div>
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
<label className="text-muted-foreground block w-[80px] text-sm">
Preview
</label>
<input
@@ -412,23 +412,23 @@ function CampaignEditor({
{
onError: (e) => {
toast.error(`${e.message}. Reverting changes.`);
setPreviewText(campaign.previewText ?? "");
setPreviewText(campaign.previewText ?? '');
},
},
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent bg-transparent focus:border-border"
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
/>
</div>
<div className=" flex items-center gap-2">
<label className="block text-sm w-[80px] text-muted-foreground">
<div className="flex items-center gap-2">
<label className="text-muted-foreground block w-[80px] text-sm">
To
</label>
{contactBooksQuery.isLoading ? (
<Spinner className="w-6 h-6" />
<Spinner className="h-6 w-6" />
) : (
<Select
value={contactBookId ?? ""}
value={contactBookId ?? ''}
onValueChange={(val) => {
// Update the campaign's contactBookId
updateCampaignMutation.mutate(
@@ -448,14 +448,14 @@ function CampaignEditor({
<SelectTrigger className="w-[300px]">
{contactBook
? `${contactBook.emoji} ${contactBook.name}`
: "Select a contact book"}
: 'Select a contact book'}
</SelectTrigger>
<SelectContent>
{contactBooksQuery.data?.map((book) => (
<SelectItem key={book.id} value={book.id}>
{book.emoji} {book.name}{" "}
<span className="text-xs text-muted-foreground ml-4">
{" "}
{book.emoji} {book.name}{' '}
<span className="text-muted-foreground ml-4 text-xs">
{' '}
{book._count.contacts} contacts
</span>
</SelectItem>
@@ -469,8 +469,8 @@ function CampaignEditor({
</AccordionItem>
</Accordion>
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<div className="mx-auto w-[700px] rounded-lg bg-gray-50 p-10">
<div className="mx-auto w-[600px]">
<Editor
initialContent={json}
onUpdate={(content) => {
@@ -478,7 +478,7 @@ function CampaignEditor({
setIsSaving(true);
deboucedUpdateCampaign();
}}
variables={["email", "firstName", "lastName"]}
variables={['email', 'firstName', 'lastName']}
uploadImage={
campaign.imageUploadSupported ? handleFileChange : undefined
}

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Breadcrumb,
@@ -7,13 +7,13 @@ import {
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@usesend/ui/src/breadcrumb";
import Link from "next/link";
import { H2 } from "@usesend/ui";
} from '@usesend/ui/src/breadcrumb';
import Link from 'next/link';
import { H2 } from '@usesend/ui';
import Spinner from "@usesend/ui/src/spinner";
import { api } from "~/trpc/react";
import { use } from "react";
import Spinner from '@usesend/ui/src/spinner';
import { api } from '~/trpc/react';
import { use } from 'react';
export default function CampaignDetailsPage({
params,
@@ -28,8 +28,8 @@ export default function CampaignDetailsPage({
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<Spinner className="w-5 h-5 text-foreground" />
<div className="flex h-screen items-center justify-center">
<Spinner className="text-foreground h-5 w-5" />
</div>
);
}
@@ -40,22 +40,22 @@ export default function CampaignDetailsPage({
const statusCards = [
{
status: "delivered",
status: 'delivered',
count: campaign.delivered,
percentage: 100,
},
{
status: "unsubscribed",
status: 'unsubscribed',
count: campaign.unsubscribed,
percentage: (campaign.unsubscribed / campaign.delivered) * 100,
},
{
status: "clicked",
status: 'clicked',
count: campaign.clicked,
percentage: (campaign.clicked / campaign.delivered) * 100,
},
{
status: "opened",
status: 'opened',
count: campaign.opened,
percentage: (campaign.opened / campaign.delivered) * 100,
},
@@ -74,9 +74,7 @@ export default function CampaignDetailsPage({
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
{campaign.name}
</BreadcrumbPage>
<BreadcrumbPage className="text-lg">{campaign.name}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -86,20 +84,20 @@ export default function CampaignDetailsPage({
{statusCards.map((card) => (
<div
key={card.status}
className="h-[100px] w-1/4 bg-secondary/10 border rounded-lg shadow p-4 flex flex-col gap-3"
className="bg-secondary/10 flex h-[100px] w-1/4 flex-col gap-3 rounded-lg border p-4 shadow"
>
<div className="flex items-center gap-3">
{card.status !== "total" ? (
{card.status !== 'total' ? (
<CampaignStatusBadge status={card.status} />
) : null}
<div className="capitalize">{card.status.toLowerCase()}</div>
</div>
<div className="flex justify-between items-end">
<div className="text-foreground font-light text-2xl font-mono">
<div className="flex items-end justify-between">
<div className="text-foreground font-mono text-2xl font-light">
{card.count}
</div>
{card.status !== "total" ? (
<div className="text-sm pb-1">
{card.status !== 'total' ? (
<div className="pb-1 text-sm">
{card.percentage.toFixed(1)}%
</div>
) : null}
@@ -110,34 +108,34 @@ export default function CampaignDetailsPage({
</div>
{campaign.html && (
<div className=" rounded-lg mt-16">
<div className="mt-16 rounded-lg">
<H2 className="mb-4">Email</H2>
<div className="p-2 rounded-lg border shadow flex flex-col gap-4 w-full">
<div className="flex w-full flex-col gap-4 rounded-lg border p-2 shadow">
<div className="flex flex-col gap-3 px-4 py-1">
<div className=" flex text-sm">
<div className="w-[70px] text-muted-foreground">Subject</div>
<div className="flex text-sm">
<div className="text-muted-foreground w-[70px]">Subject</div>
<div> {campaign.subject}</div>
</div>
<div className="flex text-sm">
<div className="w-[70px] text-muted-foreground">From</div>
<div className="text-muted-foreground w-[70px]">From</div>
<div> {campaign.from}</div>
</div>
<div className="flex text-sm items-center">
<div className="w-[70px] text-muted-foreground">Contact</div>
<div className="flex items-center text-sm">
<div className="text-muted-foreground w-[70px]">Contact</div>
<Link
href={`/contacts/${campaign.contactBookId}`}
target="_blank"
>
<div className="bg-secondary p-0.5 px-2 rounded-md ">
<div className="bg-secondary rounded-md p-0.5 px-2">
{campaign.contactBook?.emoji} &nbsp;
{campaign.contactBook?.name}
</div>
</Link>
</div>
</div>
<div className=" dark:bg-slate-50 overflow-auto text-black rounded py-8 border-t">
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? "" }} />
<div className="overflow-auto rounded border-t py-8 text-black dark:bg-slate-50">
<div dangerouslySetInnerHTML={{ __html: campaign.html ?? '' }} />
</div>
</div>
</div>
@@ -147,40 +145,40 @@ export default function CampaignDetailsPage({
}
const CampaignStatusBadge: React.FC<{ status: string }> = ({ status }) => {
let outsideColor = "bg-gray";
let insideColor = "bg-gray/50";
let outsideColor = 'bg-gray';
let insideColor = 'bg-gray/50';
switch (status) {
case "delivered":
outsideColor = "bg-green/30";
insideColor = "bg-green";
case 'delivered':
outsideColor = 'bg-green/30';
insideColor = 'bg-green';
break;
case "bounced":
case "unsubscribed":
outsideColor = "bg-red/30";
insideColor = "bg-red";
case 'bounced':
case 'unsubscribed':
outsideColor = 'bg-red/30';
insideColor = 'bg-red';
break;
case "clicked":
outsideColor = "bg-blue/30";
insideColor = "bg-blue";
case 'clicked':
outsideColor = 'bg-blue/30';
insideColor = 'bg-blue';
break;
case "opened":
outsideColor = "bg-purple/30";
insideColor = "bg-purple";
case 'opened':
outsideColor = 'bg-purple/30';
insideColor = 'bg-purple';
break;
case "complained":
outsideColor = "bg-yellow/30";
insideColor = "bg-yellow";
case 'complained':
outsideColor = 'bg-yellow/30';
insideColor = 'bg-yellow';
break;
default:
outsideColor = "bg-gray/40";
insideColor = "bg-gray";
outsideColor = 'bg-gray/40';
insideColor = 'bg-gray';
}
return (
<div
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
className={`flex items-center justify-center p-1.5 ${outsideColor} rounded-full`}
>
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
</div>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,26 +7,26 @@ import {
TableHead,
TableBody,
TableCell,
} from "@usesend/ui/src/table";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import { CampaignStatus } from "@prisma/client";
import DeleteCampaign from "./delete-campaign";
import Link from "next/link";
import DuplicateCampaign from "./duplicate-campaign";
} from '@usesend/ui/src/table';
import { api } from '~/trpc/react';
import { useUrlState } from '~/hooks/useUrlState';
import { Button } from '@usesend/ui/src/button';
import Spinner from '@usesend/ui/src/spinner';
import { formatDistanceToNow } from 'date-fns';
import { CampaignStatus } from '@prisma/client';
import DeleteCampaign from './delete-campaign';
import Link from 'next/link';
import DuplicateCampaign from './duplicate-campaign';
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
export default function CampaignList() {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const [page, setPage] = useUrlState('page', '1');
const [status, setStatus] = useUrlState('status');
const pageNumber = Number(page);
@@ -39,35 +39,32 @@ export default function CampaignList() {
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-end">
<Select
value={status ?? "all"}
onValueChange={(val) => setStatus(val === "all" ? null : val)}
value={status ?? 'all'}
onValueChange={(val) => setStatus(val === 'all' ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status ? status.toLowerCase() : "All statuses"}
{status ? status.toLowerCase() : 'All statuses'}
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className=" capitalize">
<SelectItem value="all" className="capitalize">
All statuses
</SelectItem>
<SelectItem value={CampaignStatus.DRAFT} className=" capitalize">
<SelectItem value={CampaignStatus.DRAFT} className="capitalize">
Draft
</SelectItem>
<SelectItem
value={CampaignStatus.SCHEDULED}
className=" capitalize"
>
<SelectItem value={CampaignStatus.SCHEDULED} className="capitalize">
Scheduled
</SelectItem>
<SelectItem value={CampaignStatus.SENT} className=" capitalize">
<SelectItem value={CampaignStatus.SENT} className="capitalize">
Sent
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col rounded-xl border border-border shadow">
<div className="border-border flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Status</TableHead>
<TableHead className="">Created At</TableHead>
@@ -77,9 +74,9 @@ export default function CampaignList() {
<TableBody>
{campaignsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -89,7 +86,7 @@ export default function CampaignList() {
<TableRow key={campaign.id} className="">
<TableCell className="font-medium">
<Link
className="underline underline-offset-4 decoration-dashed text-foreground hover:text-foreground"
className="text-foreground hover:text-foreground underline decoration-dashed underline-offset-4"
href={
campaign.status === CampaignStatus.DRAFT
? `/campaigns/${campaign.id}/edit`
@@ -101,12 +98,12 @@ export default function CampaignList() {
</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${
campaign.status === CampaignStatus.DRAFT
? "bg-gray/15 text-gray border border-gray/25"
? 'bg-gray/15 text-gray border-gray/25 border'
: campaign.status === CampaignStatus.SENT
? "bg-green/15 text-green border border-green/25"
: "bg-yellow/15 text-yellow border border-yellow/25"
? 'bg-green/15 text-green border-green/25 border'
: 'bg-yellow/15 text-yellow border-yellow/25 border'
}`}
>
{campaign.status.toLowerCase()}
@@ -127,7 +124,7 @@ export default function CampaignList() {
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No campaigns found
</TableCell>
</TableRow>
@@ -135,7 +132,7 @@ export default function CampaignList() {
</TableBody>
</Table>
</div>
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -16,27 +16,27 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { useRouter } from "next/navigation";
import Spinner from "@usesend/ui/src/spinner";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
import { useRouter } from 'next/navigation';
import Spinner from '@usesend/ui/src/spinner';
const campaignSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
name: z.string({ required_error: 'Name is required' }).min(1, {
message: 'Name is required',
}),
from: z.string({ required_error: "From email is required" }).min(1, {
message: "From email is required",
from: z.string({ required_error: 'From email is required' }).min(1, {
message: 'From email is required',
}),
subject: z.string({ required_error: "Subject is required" }).min(1, {
message: "Subject is required",
subject: z.string({ required_error: 'Subject is required' }).min(1, {
message: 'Subject is required',
}),
});
@@ -49,9 +49,9 @@ export default function CreateCampaign() {
const campaignForm = useForm<z.infer<typeof campaignSchema>>({
resolver: zodResolver(campaignSchema),
defaultValues: {
name: "",
from: "",
subject: "",
name: '',
from: '',
subject: '',
},
});
@@ -68,13 +68,13 @@ export default function CreateCampaign() {
onSuccess: async (data) => {
utils.campaign.getCampaigns.invalidate();
router.push(`/campaigns/${data.id}/edit`);
toast.success("Campaign created successfully");
toast.success('Campaign created successfully');
setOpen(false);
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -85,7 +85,7 @@ export default function CreateCampaign() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Create Campaign
</Button>
</DialogTrigger>
@@ -146,14 +146,14 @@ export default function CreateCampaign() {
</p>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={createCampaignMutation.isPending}
>
{createCampaignMutation.isPending ? (
<Spinner className="w-4 h-4" />
<Spinner className="h-4 w-4" />
) : (
"Create"
'Create'
)}
</Button>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,8 +25,8 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Campaign } from "@prisma/client";
} from '@usesend/ui/src/form';
import { Campaign } from '@prisma/client';
const campaignSchema = z.object({
name: z.string(),
@@ -46,8 +46,8 @@ export const DeleteCampaign: React.FC<{
async function onCampaignDelete(values: z.infer<typeof campaignSchema>) {
if (values.name !== campaign.name) {
campaignForm.setError("name", {
message: "Name does not match",
campaignForm.setError('name', {
message: 'Name does not match',
});
return;
}
@@ -66,7 +66,7 @@ export const DeleteCampaign: React.FC<{
);
}
const name = campaignForm.watch("name");
const name = campaignForm.watch('name');
return (
<Dialog
@@ -75,15 +75,15 @@ export const DeleteCampaign: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="h-[18px] w-[18px] text-red/80" />
<Trash2 className="text-red/80 h-[18px] w-[18px]" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">
{campaign.name}
</span>
? You can't reverse this.
@@ -107,7 +107,7 @@ export const DeleteCampaign: React.FC<{
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -122,7 +122,7 @@ export const DeleteCampaign: React.FC<{
deleteCampaignMutation.isPending || campaign.name !== name
}
>
{deleteCampaignMutation.isPending ? "Deleting..." : "Delete"}
{deleteCampaignMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
@@ -8,12 +8,12 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Copy } from "lucide-react";
import { Campaign } from "@prisma/client";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Copy } from 'lucide-react';
import { Campaign } from '@prisma/client';
export const DuplicateCampaign: React.FC<{
campaign: Partial<Campaign> & { id: string };
@@ -46,15 +46,15 @@ export const DuplicateCampaign: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Copy className="h-[18px] w-[18px] text-blue/80" />
<Copy className="text-blue/80 h-[18px] w-[18px]" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Campaign</DialogTitle>
<DialogDescription>
Are you sure you want to duplicate{" "}
<span className="font-semibold text-foreground">
Are you sure you want to duplicate{' '}
<span className="text-foreground font-semibold">
{campaign.name}
</span>
?
@@ -68,8 +68,8 @@ export const DuplicateCampaign: React.FC<{
disabled={duplicateCampaignMutation.isPending}
>
{duplicateCampaignMutation.isPending
? "Duplicating..."
: "Duplicate"}
? 'Duplicating...'
: 'Duplicate'}
</Button>
</div>
</div>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import CampaignList from "./campaign-list";
import CreateCampaign from "./create-campaign";
import { H1 } from "@usesend/ui";
import CampaignList from './campaign-list';
import CreateCampaign from './create-campaign';
import { H1 } from '@usesend/ui';
export default function ContactsPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Campaigns</H1>
<CreateCampaign />
</div>

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Textarea } from "@usesend/ui/src/textarea";
import { Button } from '@usesend/ui/src/button';
import { Textarea } from '@usesend/ui/src/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -17,20 +17,20 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
const contactsSchema = z.object({
contacts: z.string({ required_error: "Contacts are required" }).min(1, {
message: "Contacts are required",
contacts: z.string({ required_error: 'Contacts are required' }).min(1, {
message: 'Contacts are required',
}),
});
@@ -46,14 +46,14 @@ export default function AddContact({
const contactsForm = useForm<z.infer<typeof contactsSchema>>({
resolver: zodResolver(contactsSchema),
defaultValues: {
contacts: "",
contacts: '',
},
});
const utils = api.useUtils();
async function onContactsAdd(values: z.infer<typeof contactsSchema>) {
const contactsArray = values.contacts.split(",").map((email) => ({
const contactsArray = values.contacts.split(',').map((email) => ({
email: email.trim(),
}));
@@ -66,12 +66,12 @@ export default function AddContact({
onSuccess: async () => {
utils.contacts.contacts.invalidate();
setOpen(false);
toast.success("Contacts added successfully");
toast.success('Contacts added successfully');
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -82,7 +82,7 @@ export default function AddContact({
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add Contacts
</Button>
</DialogTrigger>
@@ -120,11 +120,11 @@ export default function AddContact({
/>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={addContactsMutation.isPending}
>
{addContactsMutation.isPending ? "Adding..." : "Add"}
{addContactsMutation.isPending ? 'Adding...' : 'Add'}
</Button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
import Spinner from "@usesend/ui/src/spinner";
} from '@usesend/ui/src/select';
import Spinner from '@usesend/ui/src/spinner';
import {
Table,
TableBody,
@@ -15,34 +15,34 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import Image from "next/image";
import { useUrlState } from "~/hooks/useUrlState";
import { api } from "~/trpc/react";
import { getGravatarUrl } from "~/utils/gravatar-utils";
import DeleteContact from "./delete-contact";
import EditContact from "./edit-contact";
import { Input } from "@usesend/ui/src/input";
import { useDebouncedCallback } from "use-debounce";
} from '@usesend/ui/src/table';
import { formatDistanceToNow } from 'date-fns';
import Image from 'next/image';
import { useUrlState } from '~/hooks/useUrlState';
import { api } from '~/trpc/react';
import { getGravatarUrl } from '~/utils/gravatar-utils';
import DeleteContact from './delete-contact';
import EditContact from './edit-contact';
import { Input } from '@usesend/ui/src/input';
import { useDebouncedCallback } from 'use-debounce';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
import { UnsubscribeReason } from "@prisma/client";
} from '@usesend/ui/src/tooltip';
import { UnsubscribeReason } from '@prisma/client';
function getUnsubscribeReason(reason: UnsubscribeReason) {
switch (reason) {
case UnsubscribeReason.BOUNCED:
return "Email bounced";
return 'Email bounced';
case UnsubscribeReason.COMPLAINED:
return "User complained";
return 'User complained';
case UnsubscribeReason.UNSUBSCRIBED:
return "User unsubscribed";
return 'User unsubscribed';
default:
return "User unsubscribed";
return 'User unsubscribed';
}
}
@@ -51,9 +51,9 @@ export default function ContactList({
}: {
contactBookId: string;
}) {
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const [search, setSearch] = useUrlState("search");
const [page, setPage] = useUrlState('page', '1');
const [status, setStatus] = useUrlState('status');
const [search, setSearch] = useUrlState('search');
const pageNumber = Number(page);
@@ -62,9 +62,9 @@ export default function ContactList({
page: pageNumber,
search: search ?? undefined,
subscribed:
status === "Subscribed"
status === 'Subscribed'
? true
: status === "Unsubscribed"
: status === 'Unsubscribed'
? false
: undefined,
});
@@ -80,35 +80,35 @@ export default function ContactList({
<div>
<Input
placeholder="Search by email or name"
className="w-[350px] mr-4"
defaultValue={search ?? ""}
className="mr-4 w-[350px]"
defaultValue={search ?? ''}
onChange={(e) => debouncedSearch(e.target.value)}
/>
</div>
<Select
value={status ?? "All"}
onValueChange={(val) => setStatus(val === "All" ? null : val)}
value={status ?? 'All'}
onValueChange={(val) => setStatus(val === 'All' ? null : val)}
>
<SelectTrigger className="w-[180px] capitalize">
{status || "All statuses"}
{status || 'All statuses'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className=" capitalize">
<SelectItem value="All" className="capitalize">
All statuses
</SelectItem>
<SelectItem value="Subscribed" className=" capitalize">
<SelectItem value="Subscribed" className="capitalize">
Subscribed
</SelectItem>
<SelectItem value="Unsubscribed" className=" capitalize">
<SelectItem value="Unsubscribed" className="capitalize">
Unsubscribed
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col rounded-xl border border-broder shadow">
<div className="border-broder flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Email</TableHead>
<TableHead>Status</TableHead>
<TableHead className="">Created At</TableHead>
@@ -118,9 +118,9 @@ export default function ContactList({
<TableBody>
{contactsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -133,7 +133,7 @@ export default function ContactList({
<Image
src={getGravatarUrl(contact.email, {
size: 75,
defaultImage: "robohash",
defaultImage: 'robohash',
})}
alt={contact.email + "'s gravatar"}
width={35}
@@ -144,7 +144,7 @@ export default function ContactList({
<span className="text-sm font-medium">
{contact.email}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{contact.firstName} {contact.lastName}
</span>
</div>
@@ -152,13 +152,13 @@ export default function ContactList({
</TableCell>
<TableCell>
{contact.subscribed ? (
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
<div className="bg-green/15 text-green border-green/25 w-[130px] rounded border py-1 text-center text-xs capitalize">
Subscribed
</div>
) : (
<Tooltip>
<TooltipTrigger>
<div className="text-center w-[130px] rounded capitalize py-1 text-xs bg-red/10 text-red border border-red/10">
<div className="bg-red/10 text-red border-red/10 w-[130px] rounded border py-1 text-center text-xs capitalize">
Unsubscribed
</div>
</TooltipTrigger>
@@ -188,7 +188,7 @@ export default function ContactList({
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No contacts found
</TableCell>
</TableRow>
@@ -196,7 +196,7 @@ export default function ContactList({
</TableBody>
</Table>
</div>
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,8 +25,8 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Contact } from "@prisma/client";
} from '@usesend/ui/src/form';
import { Contact } from '@prisma/client';
const contactSchema = z.object({
email: z.string().email(),
@@ -46,8 +46,8 @@ export const DeleteContact: React.FC<{
async function onContactDelete(values: z.infer<typeof contactSchema>) {
if (values.email !== contact.email) {
contactForm.setError("email", {
message: "Email does not match",
contactForm.setError('email', {
message: 'Email does not match',
});
return;
}
@@ -70,7 +70,7 @@ export const DeleteContact: React.FC<{
);
}
const email = contactForm.watch("email");
const email = contactForm.watch('email');
return (
<Dialog
@@ -79,15 +79,15 @@ export const DeleteContact: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red/80" />
<Trash2 className="text-red/80 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Contact</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">
{contact.email}
</span>
? You can't reverse this.
@@ -111,7 +111,7 @@ export const DeleteContact: React.FC<{
{formState.errors.email ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -126,7 +126,7 @@ export const DeleteContact: React.FC<{
deleteContactMutation.isPending || contact.email !== email
}
>
{deleteContactMutation.isPending ? "Deleting..." : "Delete"}
{deleteContactMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -17,21 +17,21 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Edit } from "lucide-react";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { Switch } from "@usesend/ui/src/switch";
import { Contact } from "@prisma/client";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Edit } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
import { Switch } from '@usesend/ui/src/switch';
import { Contact } from '@prisma/client';
const contactSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
email: z.string().email({ message: 'Invalid email address' }),
firstName: z.string().optional(),
lastName: z.string().optional(),
subscribed: z.boolean().optional(),
@@ -49,9 +49,9 @@ export const EditContact: React.FC<{
const contactForm = useForm<z.infer<typeof contactSchema>>({
resolver: zodResolver(contactSchema),
defaultValues: {
email: contact.email || "",
firstName: contact.firstName || "",
lastName: contact.lastName || "",
email: contact.email || '',
firstName: contact.firstName || '',
lastName: contact.lastName || '',
subscribed: contact.subscribed || false,
},
});
@@ -67,12 +67,12 @@ export const EditContact: React.FC<{
onSuccess: async () => {
utils.contacts.contacts.invalidate();
setOpen(false);
toast.success("Contact updated successfully");
toast.success('Contact updated successfully');
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -153,11 +153,11 @@ export const EditContact: React.FC<{
/>
<div className="flex justify-end">
<Button
className=" w-[100px] "
className="w-[100px]"
type="submit"
disabled={updateContactMutation.isPending}
>
{updateContactMutation.isPending ? "Updating..." : "Update"}
{updateContactMutation.isPending ? 'Updating...' : 'Update'}
</Button>
</div>
</form>

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { api } from '~/trpc/react';
import {
Breadcrumb,
BreadcrumbItem,
@@ -8,21 +8,21 @@ import {
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@usesend/ui/src/breadcrumb";
import Link from "next/link";
import AddContact from "./add-contact";
import ContactList from "./contact-list";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import { formatDistanceToNow } from "date-fns";
import EmojiPicker, { Theme } from "emoji-picker-react";
} from '@usesend/ui/src/breadcrumb';
import Link from 'next/link';
import AddContact from './add-contact';
import ContactList from './contact-list';
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
import { formatDistanceToNow } from 'date-fns';
import EmojiPicker, { Theme } from 'emoji-picker-react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@usesend/ui/src/popover";
import { Button } from "@usesend/ui/src/button";
import { useTheme } from "@usesend/ui";
import { use } from "react";
} from '@usesend/ui/src/popover';
import { Button } from '@usesend/ui/src/button';
import { useTheme } from '@usesend/ui';
import { use } from 'react';
export default function ContactsPage({
params,
@@ -63,7 +63,7 @@ export default function ContactsPage({
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Breadcrumb>
<BreadcrumbList>
@@ -83,7 +83,7 @@ export default function ContactsPage({
<PopoverTrigger asChild>
<Button
variant="ghost"
className="p-0 hover:bg-transparent text-lg"
className="p-0 text-lg hover:bg-transparent"
type="button"
>
{contactBookDetailQuery.data?.emoji}
@@ -100,9 +100,9 @@ export default function ContactsPage({
});
}}
theme={
theme === "system"
theme === 'system'
? Theme.AUTO
: theme === "dark"
: theme === 'dark'
? Theme.DARK
: Theme.LIGHT
}
@@ -124,9 +124,9 @@ export default function ContactsPage({
</div>
</div>
<div className="mt-16">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 lg:gap-8">
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Metrics</p>
<p className="mb-1 font-semibold">Metrics</p>
<div className="flex items-center gap-2">
<div className="text-muted-foreground w-[130px] text-sm">
Total Contacts
@@ -134,7 +134,7 @@ export default function ContactsPage({
<div className="font-mono text-sm">
{contactBookDetailQuery.data?.totalContacts !== undefined
? contactBookDetailQuery.data?.totalContacts
: "--"}
: '--'}
</div>
</div>
<div className="flex items-center gap-2">
@@ -144,7 +144,7 @@ export default function ContactsPage({
<div className="font-mono text-sm">
{contactBookDetailQuery.data?.unsubscribedContacts !== undefined
? contactBookDetailQuery.data?.unsubscribedContacts
: "--"}
: '--'}
</div>
</div>
</div>
@@ -157,7 +157,7 @@ export default function ContactsPage({
<TextWithCopyButton
value={contactBookId}
alwaysShowCopy
className="text-sm w-[130px] overflow-hidden text-ellipsis font-mono"
className="w-[130px] overflow-hidden text-ellipsis font-mono text-sm"
/>
</div>
<div className="flex items-center gap-2">
@@ -169,7 +169,7 @@ export default function ContactsPage({
? formatDistanceToNow(contactBookDetailQuery.data.createdAt, {
addSuffix: true,
})
: "--"}
: '--'}
</div>
</div>
</div>
@@ -184,7 +184,7 @@ export default function ContactsPage({
{contactBookDetailQuery.data?.campaigns.map((campaign) => (
<div key={campaign.id} className="flex items-center gap-2">
<Link href={`/campaigns/${campaign.id}`}>
<div className="text-sm hover:underline hover:decoration-dashed text-nowrap w-[200px] overflow-hidden text-ellipsis">
<div className="w-[200px] overflow-hidden text-ellipsis text-nowrap text-sm hover:underline hover:decoration-dashed">
{campaign.name}
</div>
</Link>

View File

@@ -1,22 +1,22 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { api } from "~/trpc/react";
import { useState } from "react";
import { Plus } from "lucide-react";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from '@usesend/ui/src/toaster';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,13 +25,13 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
} from '@usesend/ui/src/form';
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
import { LimitReason } from '~/lib/constants/plans';
const contactBookSchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
name: z.string({ required_error: 'Name is required' }).min(1, {
message: 'Name is required',
}),
});
@@ -50,7 +50,7 @@ export default function AddContactBook() {
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: "",
name: '',
},
});
@@ -69,7 +69,7 @@ export default function AddContactBook() {
utils.contacts.getContactBooks.invalidate();
contactBookForm.reset();
setOpen(false);
toast.success("Contact book created successfully");
toast.success('Contact book created successfully');
},
},
);
@@ -91,7 +91,7 @@ export default function AddContactBook() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add Contact Book
</Button>
</DialogTrigger>
@@ -127,15 +127,15 @@ export default function AddContactBook() {
/>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={
createContactBookMutation.isPending || limitsQuery.isLoading
}
>
{createContactBookMutation.isPending
? "Creating..."
: "Create"}
? 'Creating...'
: 'Create'}
</Button>
</div>
</form>

View File

@@ -1,18 +1,18 @@
"use client";
'use client';
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import DeleteContactBook from "./delete-contact-book";
import Link from "next/link";
import EditContactBook from "./edit-contact-book";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { useUrlState } from "~/hooks/useUrlState";
import { Input } from "@usesend/ui/src/input";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from 'date-fns';
import { api } from '~/trpc/react';
import DeleteContactBook from './delete-contact-book';
import Link from 'next/link';
import EditContactBook from './edit-contact-book';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { useUrlState } from '~/hooks/useUrlState';
import { Input } from '@usesend/ui/src/input';
import { useDebouncedCallback } from 'use-debounce';
export default function ContactBooksList() {
const [search, setSearch] = useUrlState("search");
const [search, setSearch] = useUrlState('search');
const contactBooksQuery = api.contacts.getContactBooks.useQuery({
search: search ?? undefined,
});
@@ -27,40 +27,40 @@ export default function ContactBooksList() {
<div className="mt-10">
<Input
placeholder="Search contact book"
className="w-[300px] mr-4 mb-4"
defaultValue={search ?? ""}
className="mb-4 mr-4 w-[300px]"
defaultValue={search ?? ''}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 ">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{contactBooksQuery.data?.map((contactBook) => (
<motion.div
key={contactBook.id}
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 200, damping: 10 }}
transition={{ type: 'spring', stiffness: 200, damping: 10 }}
whileTap={{ scale: 0.99 }}
className="border rounded-xl shadow hover:shadow-lg"
className="rounded-xl border shadow hover:shadow-lg"
>
<div className="flex flex-col">
<Link href={`/contacts/${contactBook.id}`} key={contactBook.id}>
<div className="flex justify-between items-center p-4 mb-4">
<div className="mb-4 flex items-center justify-between p-4">
<div className="flex items-center gap-2">
<div>{contactBook.emoji}</div>
<div className="font-semibold truncate whitespace-nowrap overflow-ellipsis w-[180px]">
<div className="w-[180px] truncate overflow-ellipsis whitespace-nowrap font-semibold">
{contactBook.name}
</div>
</div>
<div className="text-sm">
<span className="font-mono">
{contactBook._count.contacts}
</span>{" "}
</span>{' '}
contacts
</div>
</div>
</Link>
<div className="flex justify-between items-center border-t bg-muted/50">
<div className="bg-muted/50 flex items-center justify-between border-t">
<div
className="text-muted-foreground text-xs cursor-pointer w-full py-3 pl-4"
className="text-muted-foreground w-full cursor-pointer py-3 pl-4 text-xs"
onClick={() => router.push(`/contacts/${contactBook.id}`)}
>
{formatDistanceToNow(contactBook.createdAt, {

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,8 +25,8 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { ContactBook } from "@prisma/client";
} from '@usesend/ui/src/form';
import { ContactBook } from '@prisma/client';
const contactBookSchema = z.object({
name: z.string(),
@@ -49,8 +49,8 @@ export const DeleteContactBook: React.FC<{
values: z.infer<typeof contactBookSchema>,
) {
if (values.name !== contactBook.name) {
contactBookForm.setError("name", {
message: "Name does not match",
contactBookForm.setError('name', {
message: 'Name does not match',
});
return;
}
@@ -69,7 +69,7 @@ export const DeleteContactBook: React.FC<{
);
}
const name = contactBookForm.watch("name");
const name = contactBookForm.watch('name');
return (
<Dialog
@@ -77,16 +77,16 @@ export const DeleteContactBook: React.FC<{
onOpenChange={(_open) => (_open !== open ? setOpen(_open) : null)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent ">
<Trash2 className="h-[18px] w-[18px] text-red/80 hover:text-red/70" />
<Button variant="ghost" size="sm" className="p-0 hover:bg-transparent">
<Trash2 className="text-red/80 hover:text-red/70 h-[18px] w-[18px]" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Contact Book</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">
{contactBook.name}
</span>
? You can't reverse this.
@@ -110,7 +110,7 @@ export const DeleteContactBook: React.FC<{
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -127,8 +127,8 @@ export const DeleteContactBook: React.FC<{
}
>
{deleteContactBookMutation.isPending
? "Deleting..."
: "Delete"}
? 'Deleting...'
: 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -16,17 +16,17 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { api } from "~/trpc/react";
import { useState } from "react";
import { Edit } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
} from '@usesend/ui/src/form';
import { api } from '~/trpc/react';
import { useState } from 'react';
import { Edit } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
const contactBookSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
name: z.string().min(1, { message: 'Name is required' }),
});
export const EditContactBook: React.FC<{
@@ -41,12 +41,12 @@ export const EditContactBook: React.FC<{
const contactBookForm = useForm<z.infer<typeof contactBookSchema>>({
resolver: zodResolver(contactBookSchema),
defaultValues: {
name: contactBook.name || "",
name: contactBook.name || '',
},
});
async function onContactBookUpdate(
values: z.infer<typeof contactBookSchema>
values: z.infer<typeof contactBookSchema>,
) {
updateContactBookMutation.mutate(
{
@@ -57,12 +57,12 @@ export const EditContactBook: React.FC<{
onSuccess: async () => {
utils.contacts.getContactBooks.invalidate();
setOpen(false);
toast.success("Contact book updated successfully");
toast.success('Contact book updated successfully');
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -78,7 +78,7 @@ export const EditContactBook: React.FC<{
className="p-0 hover:bg-transparent"
onClick={(e) => e.stopPropagation()}
>
<Edit className="h-4 w-4 text-foreground/80 hover:text-foreground/70" />
<Edit className="text-foreground/80 hover:text-foreground/70 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
@@ -106,13 +106,13 @@ export const EditContactBook: React.FC<{
/>
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={updateContactBookMutation.isPending}
>
{updateContactBookMutation.isPending
? "Updating..."
: "Update"}
? 'Updating...'
: 'Update'}
</Button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import AddContactBook from "./add-contact-book";
import ContactBooksList from "./contact-books-list";
import { H1 } from "@usesend/ui";
import AddContactBook from './add-contact-book';
import ContactBooksList from './contact-books-list';
import { H1 } from '@usesend/ui';
export default function ContactsPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Contact books</H1>
<AddContactBook />
</div>

View File

@@ -1,22 +1,22 @@
"use client";
'use client';
import { AppSidebar } from "~/components/AppSideBar";
import { SidebarInset, SidebarTrigger } from "@usesend/ui/src/sidebar";
import { SidebarProvider } from "@usesend/ui/src/sidebar";
import { useIsMobile } from "@usesend/ui/src/hooks/use-mobile";
import { UpgradeModal } from "~/components/payments/UpgradeModal";
import { AppSidebar } from '~/components/AppSideBar';
import { SidebarInset, SidebarTrigger } from '@usesend/ui/src/sidebar';
import { SidebarProvider } from '@usesend/ui/src/sidebar';
import { useIsMobile } from '@usesend/ui/src/hooks/use-mobile';
import { UpgradeModal } from '~/components/payments/UpgradeModal';
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const isMobile = useIsMobile();
return (
<div className="h-full bg-sidebar-background">
<div className="bg-sidebar-background h-full">
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<main className="flex-1 overflow-auto h-full p-4 xl:px-40">
<main className="h-full flex-1 overflow-auto p-4 xl:px-40">
{isMobile ? (
<SidebarTrigger className="h-5 w-5 text-muted-foreground" />
<SidebarTrigger className="text-muted-foreground h-5 w-5" />
) : null}
{children}
</main>

View File

@@ -1,13 +1,13 @@
import React from "react";
import { Tabs, TabsList, TabsTrigger } from "@usesend/ui/src/tabs";
import { useUrlState } from "~/hooks/useUrlState";
import React from 'react';
import { Tabs, TabsList, TabsTrigger } from '@usesend/ui/src/tabs';
import { useUrlState } from '~/hooks/useUrlState';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
import { api } from "~/trpc/react";
} from '@usesend/ui/src/select';
import { api } from '~/trpc/react';
interface DashboardFiltersProps {
days: string;
@@ -25,19 +25,19 @@ export default function DashboardFilters({
const { data: domainsQuery } = api.domain.domains.useQuery();
const handleDomain = (val: string) => {
setDomain(val === "All Domains" ? null : val);
setDomain(val === 'All Domains' ? null : val);
};
return (
<div className="flex gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select
value={domain ?? "All Domains"}
value={domain ?? 'All Domains'}
onValueChange={(val) => handleDomain(val)}
>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-full sm:w-[180px]">
{domain
? domainsQuery?.find((d) => d.id === Number(domain))?.name
: "All Domains"}
: 'All Domains'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domains" className="capitalize">
@@ -51,10 +51,14 @@ export default function DashboardFilters({
))}
</SelectContent>
</Select>
<Tabs value={days || "7"} onValueChange={(value) => setDays(value)}>
<TabsList>
<TabsTrigger value="7">7 Days</TabsTrigger>
<TabsTrigger value="30">30 Days</TabsTrigger>
<Tabs value={days || '7'} onValueChange={(value) => setDays(value)}>
<TabsList className="w-full sm:w-auto">
<TabsTrigger value="7" className="flex-1 sm:flex-none">
7 Days
</TabsTrigger>
<TabsTrigger value="30" className="flex-1 sm:flex-none">
30 Days
</TabsTrigger>
</TabsList>
</Tabs>
</div>

View File

@@ -1,4 +1,4 @@
import React from "react";
import React from 'react';
import {
BarChart,
Bar,
@@ -10,13 +10,13 @@ import {
CartesianGrid,
AreaChart,
Area,
} from "recharts";
import { EmailStatusIcon } from "../emails/email-status-badge";
import { EmailStatus } from "@prisma/client";
import { api } from "~/trpc/react";
import Spinner from "@usesend/ui/src/spinner";
import { useTheme } from "@usesend/ui";
import { useColors } from "./hooks/useColors";
} from 'recharts';
import { EmailStatusIcon } from '../emails/email-status-badge';
import { EmailStatus } from '@prisma/client';
import { api } from '~/trpc/react';
import Spinner from '@usesend/ui/src/spinner';
import { useTheme } from '@usesend/ui';
import { useColors } from './hooks/useColors';
interface EmailChartProps {
days: number;
@@ -24,11 +24,11 @@ interface EmailChartProps {
}
const STACK_ORDER: string[] = [
"delivered",
"bounced",
"complained",
"opened",
"clicked",
'delivered',
'bounced',
'complained',
'opened',
'clicked',
] as const;
type StackKey = (typeof STACK_ORDER)[number];
@@ -66,13 +66,13 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
return (
<div className="flex flex-col gap-16">
{!statusQuery.isLoading && statusQuery.data ? (
<div className="w-full h-[450px] border shadow rounded-xl p-4">
<div>
<div className="h-[450px] w-full rounded-xl border p-4 shadow">
<div className="overflow-x-auto p-2">
{/* <div className="mb-4 text-sm">Emails</div> */}
<div className="flex gap-10">
<EmailChartItem
status={"total"}
status={'total'}
count={statusQuery.data.totalCounts.sent}
percentage={100}
/>
@@ -140,82 +140,82 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
<Tooltip
content={({ payload }) => {
const data = payload?.[0]?.payload as Record<
| "sent"
| "delivered"
| "opened"
| "clicked"
| "bounced"
| "complained",
| 'sent'
| 'delivered'
| 'opened'
| 'clicked'
| 'bounced'
| 'complained',
number
> & { date: string };
if (!data || data.sent === 0) return null;
return (
<div className=" bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
<p className="text-muted-foreground text-sm">
{data.date}
</p>
{data.delivered ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: currentColors.delivered }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Delivered
</p>
<p className="text-xs font-mono">{data.delivered}</p>
<p className="font-mono text-xs">{data.delivered}</p>
</div>
) : null}
{data.bounced ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: currentColors.bounced }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Bounced
</p>
<p className="text-xs font-mono">{data.bounced}</p>
<p className="font-mono text-xs">{data.bounced}</p>
</div>
) : null}
{data.complained ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{
backgroundColor: currentColors.complained,
}}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Complained
</p>
<p className="text-xs font-mono">{data.complained}</p>
<p className="font-mono text-xs">{data.complained}</p>
</div>
) : null}
{data.opened ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: currentColors.opened }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Opened
</p>
<p className="text-xs font-mono">{data.opened}</p>
<p className="font-mono text-xs">{data.opened}</p>
</div>
) : null}
{data.clicked ? (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ backgroundColor: currentColors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Clicked
</p>
<p className="text-xs font-mono">{data.clicked}</p>
<p className="font-mono text-xs">{data.clicked}</p>
</div>
) : null}
</div>
@@ -229,31 +229,31 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
dataKey="delivered"
stackId="a"
fill={currentColors.delivered}
shape={createRoundedTopShape("delivered")}
shape={createRoundedTopShape('delivered')}
/>
<Bar
dataKey="bounced"
stackId="a"
fill={currentColors.bounced}
shape={createRoundedTopShape("bounced")}
shape={createRoundedTopShape('bounced')}
/>
<Bar
dataKey="complained"
stackId="a"
fill={currentColors.complained}
shape={createRoundedTopShape("complained")}
shape={createRoundedTopShape('complained')}
/>
<Bar
dataKey="opened"
stackId="a"
fill={currentColors.opened}
shape={createRoundedTopShape("opened")}
shape={createRoundedTopShape('opened')}
/>
<Bar
dataKey="clicked"
stackId="a"
fill={currentColors.clicked}
shape={createRoundedTopShape("clicked")}
shape={createRoundedTopShape('clicked')}
/>
</BarChart>
</ResponsiveContainer>
@@ -266,7 +266,7 @@ export default function EmailChart({ days, domain }: EmailChartProps) {
}
type DashboardItemCardProps = {
status: EmailStatus | "total";
status: EmailStatus | 'total';
count: number;
percentage: number;
};
@@ -277,17 +277,17 @@ const DashboardItemCard: React.FC<DashboardItemCardProps> = ({
percentage,
}) => {
return (
<div className="h-[100px] w-[16%] min-w-[170px] bg-secondary/10 border shadow rounded-xl p-4 flex flex-col gap-3">
<div className="bg-secondary/10 flex h-[100px] w-[16%] min-w-[170px] flex-col gap-3 rounded-xl border p-4 shadow">
<div className="flex items-center gap-3">
{status !== "total" ? <EmailStatusIcon status={status} /> : null}
<div className=" capitalize">{status.toLowerCase()}</div>
{status !== 'total' ? <EmailStatusIcon status={status} /> : null}
<div className="capitalize">{status.toLowerCase()}</div>
</div>
<div className="flex justify-between items-end">
<div className="text-foreground font-light text-2xl font-mono">
<div className="flex items-end justify-between">
<div className="text-foreground font-mono text-2xl font-light">
{count}
</div>
{status !== "total" ? (
<div className="text-sm pb-1">
{status !== 'total' ? (
<div className="pb-1 text-sm">
{count > 0 ? (percentage * 100).toFixed(0) : 0}%
</div>
) : null}
@@ -303,41 +303,41 @@ const EmailChartItem: React.FC<DashboardItemCardProps> = ({
}) => {
const currentColors = useColors();
const getColorForStatus = (status: EmailStatus | "total"): string => {
const getColorForStatus = (status: EmailStatus | 'total'): string => {
switch (status) {
case "DELIVERED":
case 'DELIVERED':
return currentColors.delivered;
case "BOUNCED":
case 'BOUNCED':
return currentColors.bounced;
case "COMPLAINED":
case 'COMPLAINED':
return currentColors.complained;
case "OPENED":
case 'OPENED':
return currentColors.opened;
case "CLICKED":
case 'CLICKED':
return currentColors.clicked;
case "total":
case 'total':
default:
return "#6b7280"; // gray-500 for total and other statuses
return '#6b7280'; // gray-500 for total and other statuses
}
};
return (
<div className="flex gap-3 items-stretch font-mono">
<div className="flex items-stretch gap-3 font-mono">
<div>
<div className=" flex items-center gap-2">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[3px]"
className="h-2.5 w-2.5 rounded-[3px]"
style={{ backgroundColor: getColorForStatus(status) }}
></div>
<div className="text-xs uppercase text-muted-foreground ">
<div className="text-muted-foreground text-xs uppercase">
{status.toLowerCase()}
</div>
</div>
<div className="mt-1 -ml-0.5 ">
<span className="text-xl font-mono">{count}</span>
<span className="text-xs ml-2 font-mono">
{status !== "total"
<div className="-ml-0.5 mt-1">
<span className="font-mono text-xl">{count}</span>
<span className="ml-2 font-mono text-xs">
{status !== 'total'
? `(${count > 0 ? (percentage * 100).toFixed(0) : 0}%)`
: null}
</span>

View File

@@ -1,27 +1,27 @@
import { useTheme } from "@usesend/ui";
import { useTheme } from '@usesend/ui';
export function useColors() {
const { resolvedTheme } = useTheme();
const lightColors = {
delivered: "#40a02b",
bounced: "#d20f39",
complained: "#df8e1d",
opened: "#8839ef",
clicked: "#04a5e5",
xaxis: "#6D6F84",
delivered: '#40a02b',
bounced: '#d20f39',
complained: '#df8e1d',
opened: '#8839ef',
clicked: '#04a5e5',
xaxis: '#6D6F84',
};
const darkColors = {
delivered: "#a6e3a1",
bounced: "#f38ba8",
complained: "#F9E2AF",
opened: "#cba6f7",
clicked: "#93c5fd",
xaxis: "#AAB1CD",
delivered: '#a6e3a1',
bounced: '#f38ba8',
complained: '#F9E2AF',
opened: '#cba6f7',
clicked: '#93c5fd',
xaxis: '#AAB1CD',
};
const currentColors = resolvedTheme === "dark" ? darkColors : lightColors;
const currentColors = resolvedTheme === 'dark' ? darkColors : lightColors;
return currentColors;
}

View File

@@ -1,31 +1,31 @@
"use client";
'use client';
import EmailChart from "./email-chart";
import DashboardFilters from "./dashboard-filters";
import { H1 } from "@usesend/ui";
import { useUrlState } from "~/hooks/useUrlState";
import { ReputationMetrics } from "./reputation-metrics";
import EmailChart from './email-chart';
import DashboardFilters from './dashboard-filters';
import { H1 } from '@usesend/ui';
import { useUrlState } from '~/hooks/useUrlState';
import { ReputationMetrics } from './reputation-metrics';
export default function Dashboard() {
const [days, setDays] = useUrlState("days", "7");
const [domain, setDomain] = useUrlState("domain");
const [days, setDays] = useUrlState('days', '7');
const [domain, setDomain] = useUrlState('domain');
return (
<div>
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<div className="mb-10 flex items-center justify-between">
<H1>Analytics</H1>
<DashboardFilters
days={days ?? "7"}
days={days ?? '7'}
setDays={setDays}
domain={domain}
setDomain={setDomain}
/>
</div>
<div className=" space-y-12">
<EmailChart days={Number(days ?? "7")} domain={domain} />
<div className="space-y-12">
<EmailChart days={Number(days ?? '7')} domain={domain} />
<ReputationMetrics days={Number(days ?? "7")} domain={domain} />
<ReputationMetrics days={Number(days ?? '7')} domain={domain} />
</div>
</div>
</div>

View File

@@ -3,14 +3,14 @@ import {
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
} from '@usesend/ui/src/tooltip';
import {
CheckCircle2,
CheckCircle2Icon,
InfoIcon,
OctagonAlertIcon,
TriangleAlertIcon,
} from "lucide-react";
} from 'lucide-react';
import {
Bar,
BarChart,
@@ -19,15 +19,15 @@ import {
Tooltip as RechartsTooltip,
CartesianGrid,
YAxis,
} from "recharts";
} from 'recharts';
import {
HARD_BOUNCE_RISK_RATE,
HARD_BOUNCE_WARNING_RATE,
COMPLAINED_WARNING_RATE,
COMPLAINED_RISK_RATE,
} from "~/lib/constants";
import { api } from "~/trpc/react";
import { useColors } from "./hooks/useColors";
} from '~/lib/constants';
import { api } from '~/trpc/react';
import { useColors } from './hooks/useColors';
interface ReputationMetricsProps {
days: number;
@@ -35,9 +35,9 @@ interface ReputationMetricsProps {
}
enum ACCOUNT_STATUS {
HEALTHY = "HEALTHY",
WARNING = "WARNING",
RISK = "RISK",
HEALTHY = 'HEALTHY',
WARNING = 'WARNING',
RISK = 'RISK',
}
const CustomLabel = ({ value, stroke }: { value: string; stroke: string }) => {
@@ -59,7 +59,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
const bouncedMetric = metrics
? [
{
name: "Bounce Rate",
name: 'Bounce Rate',
value: metrics.bounceRate,
},
]
@@ -68,7 +68,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
const complaintMetric = metrics
? [
{
name: "Complaint Rate",
name: 'Complaint Rate',
value: metrics.complaintRate,
},
]
@@ -90,14 +90,14 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
return (
<TooltipProvider>
<div className="flex gap-10 w-full">
<div className="w-1/2 border rounded-xl shadow p-4">
<div className="flex w-full flex-col gap-10 sm:flex-row">
<div className="w-full rounded-xl border p-4 shadow sm:w-1/2">
<div className="flex justify-between">
<div className=" flex items-center gap-2">
<div className="flex items-center gap-2">
<div className="text-muted-foreground font-mono">Bounce Rate</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
<InfoIcon className="text-muted-foreground h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent className="w-[300px]">
The percentage of emails sent from your account that resulted
@@ -108,7 +108,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
<div></div>
</div>
<div className="flex items-baseline gap-4">
<div className="text-2xl mt-2 font-mono">
<div className="mt-2 font-mono text-2xl">
{metrics?.bounceRate.toFixed(2)}%
</div>
<StatusBadge status={bounceStatus} />
@@ -147,8 +147,8 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
y={HARD_BOUNCE_WARNING_RATE}
stroke={`${colors.complained}A0`}
label={{
value: "",
position: "insideBottomLeft",
value: '',
position: 'insideBottomLeft',
fill: colors.complained,
fontSize: 12,
}}
@@ -169,7 +169,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
stroke={`${colors.bounced}A0`}
label={{
value: ``,
position: "insideBottomLeft",
position: 'insideBottomLeft',
fill: colors.bounced,
fontSize: 12,
}}
@@ -185,43 +185,43 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
if (!data) return null;
return (
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
<p className="text-muted-foreground text-sm">
{data.name}
</p>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Current
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{data.value.toFixed(2)}%
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.complained }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Warning at
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{HARD_BOUNCE_WARNING_RATE}%
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.bounced }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Risk at
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{HARD_BOUNCE_RISK_RATE}%
</p>
</div>
@@ -240,14 +240,14 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
</BarChart>
</ResponsiveContainer>
</div>
<div className="w-1/2 border rounded-xl shadow p-4">
<div className=" flex items-center gap-2">
<div className=" text-muted-foreground font-mono">
<div className="w-full rounded-xl border p-4 shadow sm:w-1/2">
<div className="flex items-center gap-2">
<div className="text-muted-foreground font-mono">
Complaint Rate
</div>
<Tooltip>
<TooltipTrigger>
<InfoIcon className=" h-3.5 w-3.5 text-muted-foreground" />
<InfoIcon className="text-muted-foreground h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent className="w-[300px]">
The percentage of emails sent from your account that resulted in
@@ -256,7 +256,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
</Tooltip>
</div>
<div className="flex items-baseline gap-4">
<div className="text-2xl mt-2 font-mono">
<div className="mt-2 font-mono text-2xl">
{metrics?.complaintRate.toFixed(2)}%
</div>
<StatusBadge status={complaintStatus} />
@@ -289,8 +289,8 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
y={COMPLAINED_WARNING_RATE}
stroke={`${colors.complained}A0`}
label={{
value: "",
position: "insideBottomLeft",
value: '',
position: 'insideBottomLeft',
fill: colors.complained,
fontSize: 12,
}}
@@ -308,7 +308,7 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
stroke={`${colors.bounced}A0`}
label={{
value: ``,
position: "insideBottomLeft",
position: 'insideBottomLeft',
fill: colors.bounced,
fontSize: 12,
}}
@@ -324,43 +324,43 @@ export function ReputationMetrics({ days, domain }: ReputationMetricsProps) {
if (!data) return null;
return (
<div className="bg-background border shadow-lg p-2 rounded-xl flex flex-col gap-2 px-4">
<p className="text-sm text-muted-foreground">
<div className="bg-background flex flex-col gap-2 rounded-xl border p-2 px-4 shadow-lg">
<p className="text-muted-foreground text-sm">
{data.name}
</p>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.clicked }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Current
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{data.value.toFixed(2)}%
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.complained }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Warning at
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{COMPLAINED_WARNING_RATE}%
</p>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<div
className="w-2.5 h-2.5 rounded-[2px]"
className="h-2.5 w-2.5 rounded-[2px]"
style={{ background: colors.bounced }}
></div>
<p className="text-xs text-muted-foreground w-[70px]">
<p className="text-muted-foreground w-[70px] text-xs">
Risk at
</p>
<p className="text-xs font-mono">
<p className="font-mono text-xs">
{COMPLAINED_RISK_RATE}%
</p>
</div>
@@ -388,22 +388,22 @@ export const StatusBadge: React.FC<{ status: ACCOUNT_STATUS }> = ({
status,
}) => {
const className =
status === "HEALTHY"
? " text-success border-success"
: status === "WARNING"
? " text-warning border-warning"
: " text-destructive border-destructive";
status === 'HEALTHY'
? ' text-success border-success'
: status === 'WARNING'
? ' text-warning border-warning'
: ' text-destructive border-destructive';
const StatusIcon =
status === "HEALTHY"
status === 'HEALTHY'
? CheckCircle2Icon
: status === "WARNING"
: status === 'WARNING'
? TriangleAlertIcon
: OctagonAlertIcon;
return (
<div
className={` capitalize text-xs ${className} flex gap-1 items-center rounded-lg`}
className={`text-xs capitalize ${className} flex items-center gap-1 rounded-lg`}
>
<StatusIcon className="h-3.5 w-3.5" />
{status.toLowerCase()}

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,15 +9,15 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import { api } from "~/trpc/react";
import { useState } from "react";
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from "lucide-react";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { CheckIcon, ClipboardCopy, Eye, EyeOff, Plus } from 'lucide-react';
import { toast } from '@usesend/ui/src/toaster';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -26,25 +26,25 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
const apiKeySchema = z.object({
name: z.string({ required_error: "Name is required" }).min(1, {
message: "Name is required",
name: z.string({ required_error: 'Name is required' }).min(1, {
message: 'Name is required',
}),
domainId: z.string().optional(),
});
export default function AddApiKey() {
const [open, setOpen] = useState(false);
const [apiKey, setApiKey] = useState("");
const [apiKey, setApiKey] = useState('');
const createApiKeyMutation = api.apiKey.createToken.useMutation();
const [isCopied, setIsCopied] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
@@ -56,8 +56,8 @@ export default function AddApiKey() {
const apiKeyForm = useForm<z.infer<typeof apiKeySchema>>({
resolver: zodResolver(apiKeySchema),
defaultValues: {
name: "",
domainId: "all",
name: '',
domainId: 'all',
},
});
@@ -65,9 +65,9 @@ export default function AddApiKey() {
createApiKeyMutation.mutate(
{
name: values.name,
permission: "FULL",
permission: 'FULL',
domainId:
values.domainId === "all" ? undefined : Number(values.domainId),
values.domainId === 'all' ? undefined : Number(values.domainId),
},
{
onSuccess: (data) => {
@@ -75,7 +75,7 @@ export default function AddApiKey() {
setApiKey(data);
apiKeyForm.reset();
},
}
},
);
}
@@ -89,10 +89,10 @@ export default function AddApiKey() {
function copyAndClose() {
handleCopy();
setApiKey("");
setApiKey('');
setOpen(false);
setShowApiKey(false);
toast.success("API key copied to clipboard");
toast.success('API key copied to clipboard');
}
return (
@@ -102,7 +102,7 @@ export default function AddApiKey() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add API Key
</Button>
</DialogTrigger>
@@ -111,7 +111,7 @@ export default function AddApiKey() {
<DialogHeader>
<DialogTitle>Copy API key</DialogTitle>
</DialogHeader>
<div className="py-1 bg-secondary rounded-lg px-4 flex items-center justify-between mt-2">
<div className="bg-secondary mt-2 flex items-center justify-between rounded-lg px-4 py-1">
<div>
{showApiKey ? (
<p className="text-sm">{apiKey}</p>
@@ -120,7 +120,7 @@ export default function AddApiKey() {
{Array.from({ length: 40 }).map((_, index) => (
<div
key={index}
className="w-1 h-1 bg-muted-foreground rounded-lg"
className="bg-muted-foreground h-1 w-1 rounded-lg"
/>
))}
</div>
@@ -129,7 +129,7 @@ export default function AddApiKey() {
<div className="flex gap-4">
<Button
variant="ghost"
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
className="cursor-pointer p-0 hover:bg-transparent group-hover:opacity-100"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
@@ -141,11 +141,11 @@ export default function AddApiKey() {
<Button
variant="ghost"
className="hover:bg-transparent p-0 cursor-pointer group-hover:opacity-100"
className="cursor-pointer p-0 hover:bg-transparent group-hover:opacity-100"
onClick={handleCopy}
>
{isCopied ? (
<CheckIcon className="h-4 w-4 text-green" />
<CheckIcon className="text-green h-4 w-4" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
@@ -218,7 +218,7 @@ export default function AddApiKey() {
>
{domain.name}
</SelectItem>
)
),
)}
</SelectContent>
</Select>
@@ -230,11 +230,11 @@ export default function AddApiKey() {
/>
<div className="flex justify-end">
<Button
className=" w-[100px] hover:bg-gray-100 focus:bg-gray-100"
className="w-[100px] hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending ? "Creating..." : "Create"}
{createApiKeyMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</div>
</form>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,21 +7,21 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import DeleteApiKey from "./delete-api-key";
import Spinner from "@usesend/ui/src/spinner";
} from '@usesend/ui/src/table';
import { formatDistanceToNow } from 'date-fns';
import { api } from '~/trpc/react';
import DeleteApiKey from './delete-api-key';
import Spinner from '@usesend/ui/src/spinner';
export default function ApiList() {
const apiKeysQuery = api.apiKey.getApiKeys.useQuery();
return (
<div className="mt-10">
<div className="border rounded-xl shadow">
<div className="rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Name</TableHead>
<TableHead>Token</TableHead>
<TableHead>Permission</TableHead>
@@ -34,16 +34,16 @@ export default function ApiList() {
<TableBody>
{apiKeysQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={7} className="text-center py-4">
<TableCell colSpan={7} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : apiKeysQuery.data?.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={7} className="text-center py-4">
<TableCell colSpan={7} className="py-4 text-center">
<p>No API keys added</p>
</TableCell>
</TableRow>
@@ -55,13 +55,15 @@ export default function ApiList() {
<TableCell>{apiKey.permission}</TableCell>
<TableCell>
{apiKey.domainId
? apiKey.domain?.name ?? "Domain removed"
: "All domains"}
? (apiKey.domain?.name ?? 'Domain removed')
: 'All domains'}
</TableCell>
<TableCell>
{apiKey.lastUsed
? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })
: "Never"}
? formatDistanceToNow(apiKey.lastUsed, {
addSuffix: true,
})
: 'Never'}
</TableCell>
<TableCell>
{formatDistanceToNow(apiKey.createdAt, { addSuffix: true })}

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,15 +9,15 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { ApiKey } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { ApiKey } from '@prisma/client';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -26,7 +26,7 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
const apiKeySchema = z.object({
name: z.string(),
@@ -46,8 +46,8 @@ export const DeleteApiKey: React.FC<{
async function onDomainDelete(values: z.infer<typeof apiKeySchema>) {
if (values.name !== apiKey.name) {
apiKeyForm.setError("name", {
message: "Name does not match",
apiKeyForm.setError('name', {
message: 'Name does not match',
});
return;
}
@@ -66,7 +66,7 @@ export const DeleteApiKey: React.FC<{
);
}
const name = apiKeyForm.watch("name");
const name = apiKeyForm.watch('name');
return (
<Dialog
@@ -75,15 +75,15 @@ export const DeleteApiKey: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red/80" />
<Trash2 className="text-red/80 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete API key</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{apiKey.name}</span>
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">{apiKey.name}</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
@@ -105,7 +105,7 @@ export const DeleteApiKey: React.FC<{
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -120,7 +120,7 @@ export const DeleteApiKey: React.FC<{
deleteApiKeyMutation.isPending || apiKey.name !== name
}
>
{deleteApiKeyMutation.isPending ? "Deleting..." : "Delete"}
{deleteApiKeyMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import AddApiKey from "./add-api-key";
import ApiList from "./api-list";
import { H1 } from "@usesend/ui";
import AddApiKey from './add-api-key';
import ApiList from './api-list';
import { H1 } from '@usesend/ui';
export default function ApiKeysPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>API Keys</H1>
<AddApiKey />
</div>

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { SettingsNavButton } from "./settings-nav-button";
import { SettingsNavButton } from './settings-nav-button';
export const dynamic = "force-static";
export const dynamic = 'force-static';
export default function ApiKeysPage({
children,
@@ -11,8 +11,8 @@ export default function ApiKeysPage({
}) {
return (
<div>
<h1 className="font-bold text-lg">Developer settings</h1>
<div className="flex gap-4 mt-4">
<h1 className="text-lg font-bold">Developer settings</h1>
<div className="mt-4 flex gap-4">
<SettingsNavButton href="/dev-settings">API Keys</SettingsNavButton>
<SettingsNavButton href="/dev-settings/smtp">SMTP</SettingsNavButton>
</div>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import AddApiKey from "./api-keys/add-api-key";
import ApiList from "./api-keys/api-list";
import { H1 } from "@usesend/ui";
import AddApiKey from './api-keys/add-api-key';
import ApiList from './api-keys/api-list';
import { H1 } from '@usesend/ui';
export default function ApiKeysPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>API Keys</H1>
<AddApiKey />
</div>

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import Link from "next/link";
import { usePathname } from "next/navigation";
import React from "react";
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import React from 'react';
export const SettingsNavButton: React.FC<{
href: string;
@@ -15,13 +15,13 @@ export const SettingsNavButton: React.FC<{
if (comingSoon) {
return (
<div className="flex items-center justify-between hover:text-foreground cursor-not-allowed mt-1">
<div className="hover:text-foreground mt-1 flex cursor-not-allowed items-center justify-between">
<div
className={`flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-foreground cursor-not-allowed ${isActive ? " bg-secondary" : "text-muted-foreground"}`}
className={`hover:text-foreground flex cursor-not-allowed items-center gap-3 rounded-lg px-3 py-2 transition-all ${isActive ? 'bg-secondary' : 'text-muted-foreground'}`}
>
{children}
</div>
<div className="text-muted-foreground px-4 py-0.5 text-xs bg-muted rounded-full">
<div className="text-muted-foreground bg-muted rounded-full px-4 py-0.5 text-xs">
soon
</div>
</div>
@@ -31,7 +31,7 @@ export const SettingsNavButton: React.FC<{
return (
<Link
href={href}
className={`flex text-sm items-center mt-1 gap-3 rounded px-2 py-1 transition-all hover:text-foreground ${isActive ? " bg-accent" : "text-muted-foreground"}`}
className={`hover:text-foreground mt-1 flex items-center gap-3 rounded px-2 py-1 text-sm transition-all ${isActive ? 'bg-accent' : 'text-muted-foreground'}`}
>
{children}
</Link>

View File

@@ -1,15 +1,15 @@
import * as React from "react";
import * as React from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@usesend/ui/src/card";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import { env } from "~/env";
} from '@usesend/ui/src/card';
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
import { env } from '~/env';
export const dynamic = "force-dynamic";
export const dynamic = 'force-dynamic';
export default function ExampleCard() {
const host = env.SMTP_HOST;
@@ -29,35 +29,35 @@ export default function ExampleCard() {
<div>
<strong>Host:</strong>
<TextWithCopyButton
className="ml-1 border bg-primary/10 rounded-lg mt-1 p-2 w-full "
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg border p-2"
value={host}
></TextWithCopyButton>
</div>
<div>
<strong>Port:</strong>
<TextWithCopyButton
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10 font-mono"
value={"465"}
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2 font-mono"
value={'465'}
></TextWithCopyButton>
<p className="ml-1 mt-1 text-zinc-500 text-sm ">
For encrypted/TLS connections use{" "}
<strong className="font-mono">2465</strong>,{" "}
<strong className="font-mono">587</strong> or{" "}
<p className="ml-1 mt-1 text-sm text-zinc-500">
For encrypted/TLS connections use{' '}
<strong className="font-mono">2465</strong>,{' '}
<strong className="font-mono">587</strong> or{' '}
<strong className="font-mono">2587</strong>
</p>
</div>
<div>
<strong>User:</strong>
<TextWithCopyButton
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10"
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2"
value={user}
></TextWithCopyButton>
</div>
<div>
<strong>Password:</strong>
<TextWithCopyButton
className="ml-1 rounded-lg mt-1 p-2 w-full bg-primary/10"
value={"YOUR_API_KEY"}
className="bg-primary/10 ml-1 mt-1 w-full rounded-lg p-2"
value={'YOUR_API_KEY'}
></TextWithCopyButton>
</div>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
@@ -19,16 +19,16 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { Domain } from "@prisma/client";
import { useRouter } from "next/navigation";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { Domain } from '@prisma/client';
import { useRouter } from 'next/navigation';
import { toast } from '@usesend/ui/src/toaster';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const domainSchema = z.object({
domain: z.string(),
@@ -36,7 +36,7 @@ const domainSchema = z.object({
export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
const [open, setOpen] = useState(false);
const [domainName, setDomainName] = useState("");
const [domainName, setDomainName] = useState('');
const deleteDomainMutation = api.domain.deleteDomain.useMutation();
const domainForm = useForm<z.infer<typeof domainSchema>>({
@@ -49,8 +49,8 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
async function onDomainDelete(values: z.infer<typeof domainSchema>) {
if (values.domain !== domain.name) {
domainForm.setError("domain", {
message: "Domain name does not match",
domainForm.setError('domain', {
message: 'Domain name does not match',
});
return;
}
@@ -64,7 +64,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
utils.domain.domains.invalidate();
setOpen(false);
toast.success(`Domain ${domain.name} deleted`);
router.replace("/domains");
router.replace('/domains');
},
},
);
@@ -84,8 +84,8 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
<DialogHeader>
<DialogTitle>Delete domain</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{domain.name}</span>
Are you sure you want to delete{' '}
<span className="text-foreground font-semibold">{domain.name}</span>
? You can't reverse this.
</DialogDescription>
</DialogHeader>
@@ -106,7 +106,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
{formState.errors.domain ? (
<FormMessage />
) : (
<FormDescription className=" text-transparent">
<FormDescription className="text-transparent">
.
</FormDescription>
)}
@@ -119,7 +119,7 @@ export const DeleteDomain: React.FC<{ domain: Domain }> = ({ domain }) => {
variant="destructive"
disabled={deleteDomainMutation.isPending}
>
{deleteDomainMutation.isPending ? "Deleting..." : "Delete"}
{deleteDomainMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</div>
</form>

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Domain, DomainStatus } from "@prisma/client";
import { api } from '~/trpc/react';
import { Domain, DomainStatus } from '@prisma/client';
import {
Breadcrumb,
BreadcrumbItem,
@@ -9,8 +9,8 @@ import {
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@usesend/ui/src/breadcrumb";
import { DomainStatusBadge } from "../domain-badge";
} from '@usesend/ui/src/breadcrumb';
import { DomainStatusBadge } from '../domain-badge';
import {
Table,
TableBody,
@@ -18,16 +18,16 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { TextWithCopyButton } from "@usesend/ui/src/text-with-copy";
import React, { use } from "react";
import { Switch } from "@usesend/ui/src/switch";
import DeleteDomain from "./delete-domain";
import SendTestMail from "./send-test-mail";
import { Button } from "@usesend/ui/src/button";
import Link from "next/link";
import { toast } from "@usesend/ui/src/toaster";
import { H1 } from "@usesend/ui";
} from '@usesend/ui/src/table';
import { TextWithCopyButton } from '@usesend/ui/src/text-with-copy';
import React, { use } from 'react';
import { Switch } from '@usesend/ui/src/switch';
import DeleteDomain from './delete-domain';
import SendTestMail from './send-test-mail';
import { Button } from '@usesend/ui/src/button';
import Link from 'next/link';
import { toast } from '@usesend/ui/src/toaster';
import { H1 } from '@usesend/ui';
export default function DomainItemPage({
params,
@@ -65,7 +65,7 @@ export default function DomainItemPage({
<p>Loading...</p>
) : (
<div className="flex flex-col gap-8">
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* <div className="flex items-center gap-4">
<H1>{domainQuery.data?.name}</H1>
@@ -81,7 +81,7 @@ export default function DomainItemPage({
</BreadcrumbItem>
<BreadcrumbSeparator className="text-lg" />
<BreadcrumbItem>
<BreadcrumbPage className="text-lg ">
<BreadcrumbPage className="text-lg">
{domainQuery.data?.name}
</BreadcrumbPage>
</BreadcrumbItem>
@@ -98,10 +98,10 @@ export default function DomainItemPage({
<div>
<Button variant="outline" onClick={handleVerify}>
{domainQuery.data?.isVerifying
? "Verifying..."
? 'Verifying...'
: domainQuery.data?.status === DomainStatus.SUCCESS
? "Verify again"
: "Verify domain"}
? 'Verify again'
: 'Verify domain'}
</Button>
</div>
{domainQuery.data ? (
@@ -110,8 +110,8 @@ export default function DomainItemPage({
</div>
</div>
<div className=" border rounded-lg p-4 shadow">
<p className="font-semibold text-xl">DNS records</p>
<div className="rounded-lg border p-4 shadow">
<p className="text-xl font-semibold">DNS records</p>
<Table className="mt-2">
<TableHeader className="">
<TableRow className="">
@@ -128,7 +128,7 @@ export default function DomainItemPage({
<TableCell className="">MX</TableCell>
<TableCell>
<TextWithCopyButton
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
/>
</TableCell>
<TableCell className="">
@@ -144,7 +144,7 @@ export default function DomainItemPage({
<TableCell className="">10</TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
status={domainQuery.data?.spfDetails ?? 'NOT_STARTED'}
/>
</TableCell>
</TableRow>
@@ -152,7 +152,7 @@ export default function DomainItemPage({
<TableCell className="">TXT</TableCell>
<TableCell>
<TextWithCopyButton
value={`${domainQuery.data?.dkimSelector ?? "unsend"}._domainkey${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
value={`${domainQuery.data?.dkimSelector ?? 'unsend'}._domainkey${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
/>
</TableCell>
<TableCell className="">
@@ -165,7 +165,7 @@ export default function DomainItemPage({
<TableCell className=""></TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.dkimStatus ?? "NOT_STARTED"}
status={domainQuery.data?.dkimStatus ?? 'NOT_STARTED'}
/>
</TableCell>
</TableRow>
@@ -173,7 +173,7 @@ export default function DomainItemPage({
<TableCell className="">TXT</TableCell>
<TableCell>
<TextWithCopyButton
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ""}`}
value={`mail${domainQuery.data?.subdomain ? `.${domainQuery.data.subdomain}` : ''}`}
/>
</TableCell>
<TableCell className="">
@@ -186,15 +186,15 @@ export default function DomainItemPage({
<TableCell className=""></TableCell>
<TableCell className="">
<DnsVerificationStatus
status={domainQuery.data?.spfDetails ?? "NOT_STARTED"}
status={domainQuery.data?.spfDetails ?? 'NOT_STARTED'}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell className="">TXT</TableCell>
<TableCell>
<div className="flex gap-2 items-center">
<span className="text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
(recommended)
</span>
<TextWithCopyButton value="_dmarc" />
@@ -211,7 +211,7 @@ export default function DomainItemPage({
<TableCell className="">
<DnsVerificationStatus
status={
domainQuery.data?.dmarcAdded ? "SUCCESS" : "NOT_STARTED"
domainQuery.data?.dmarcAdded ? 'SUCCESS' : 'NOT_STARTED'
}
/>
</TableCell>
@@ -244,7 +244,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
{
onSuccess: () => {
utils.domain.invalidate();
toast.success("Click tracking updated");
toast.success('Click tracking updated');
},
},
);
@@ -257,18 +257,18 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
{
onSuccess: () => {
utils.domain.invalidate();
toast.success("Open tracking updated");
toast.success('Open tracking updated');
},
},
);
}
return (
<div className="rounded-lg shadow p-4 border flex flex-col gap-6">
<p className="font-semibold text-xl">Settings</p>
<div className="flex flex-col gap-6 rounded-lg border p-4 shadow">
<p className="text-xl font-semibold">Settings</p>
<div className="flex flex-col gap-1">
<div className="font-semibold">Click tracking</div>
<p className=" text-muted-foreground text-sm">
Track any links in your emails content.{" "}
<p className="text-muted-foreground text-sm">
Track any links in your emails content.{' '}
</p>
<Switch
checked={clickTracking}
@@ -279,7 +279,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
<div className="flex flex-col gap-1">
<div className="font-semibold">Open tracking</div>
<p className=" text-muted-foreground text-sm">
<p className="text-muted-foreground text-sm">
Unsend adds a tracking pixel to every email you send. This allows you
to see how many people open your emails. This will affect the delivery
rate of your emails.
@@ -292,7 +292,7 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
</div>
<div className="flex flex-col gap-2">
<p className="font-semibold text-lg text-destructive">Danger</p>
<p className="text-destructive text-lg font-semibold">Danger</p>
<p className="text-destructive text-sm font-semibold">
Deleting a domain will stop sending emails with this domain.
@@ -304,27 +304,27 @@ const DomainSettings: React.FC<{ domain: Domain }> = ({ domain }) => {
};
const DnsVerificationStatus: React.FC<{ status: string }> = ({ status }) => {
let badgeColor = "bg-gray/10 text-gray border-gray/10"; // Default color
let badgeColor = 'bg-gray/10 text-gray border-gray/10'; // Default color
switch (status) {
case DomainStatus.SUCCESS:
badgeColor = "bg-green/15 text-green border border-green/25";
badgeColor = 'bg-green/15 text-green border border-green/25';
break;
case DomainStatus.FAILED:
badgeColor = "bg-red/10 text-red border border-red/10";
badgeColor = 'bg-red/10 text-red border border-red/10';
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
badgeColor = 'bg-yellow/20 text-yellow border border-yellow/10';
break;
default:
badgeColor = "bg-gray/10 text-gray border border-gray/20";
badgeColor = 'bg-gray/10 text-gray border border-gray/20';
}
return (
<div
className={` text-xs text-center min-w-[70px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
className={`flex min-w-[70px] items-center justify-center rounded-md py-1 text-center text-xs capitalize ${badgeColor}`}
>
{status.split("_").join(" ").toLowerCase()}
{status.split('_').join(' ').toLowerCase()}
</div>
);
};

View File

@@ -1,11 +1,11 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { api } from "~/trpc/react";
import React from "react";
import { Domain } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
import { SendHorizonal } from "lucide-react";
import { Button } from '@usesend/ui/src/button';
import { api } from '~/trpc/react';
import React from 'react';
import { Domain } from '@prisma/client';
import { toast } from '@usesend/ui/src/toaster';
import { SendHorizonal } from 'lucide-react';
// Removed dialog and example code. Clicking the button now sends the email directly.
export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
@@ -25,7 +25,7 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
toast.success(`Test email sent`);
},
onError: (err) => {
toast.error(err.message || "Failed to send test email");
toast.error(err.message || 'Failed to send test email');
},
},
);
@@ -36,10 +36,10 @@ export const SendTestMail: React.FC<{ domain: Domain }> = ({ domain }) => {
onClick={handleSendTestEmail}
disabled={sendTestEmailFromDomainMutation.isPending}
>
<SendHorizonal className="h-4 w-4 mr-2" />
<SendHorizonal className="mr-2 h-4 w-4" />
{sendTestEmailFromDomainMutation.isPending
? "Sending email..."
: "Send test email"}
? 'Sending email...'
: 'Send test email'}
</Button>
);
};

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -17,31 +17,31 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import * as tldts from "tldts";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { api } from '~/trpc/react';
import { useEffect, useState } from 'react';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import * as tldts from 'tldts';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { toast } from "@usesend/ui/src/toaster";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
} from '@usesend/ui/src/select';
import { toast } from '@usesend/ui/src/toaster';
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
import { LimitReason } from '~/lib/constants/plans';
const domainSchema = z.object({
region: z.string().optional(),
domain: z.string({ required_error: "Domain is required" }).min(1, {
message: "Domain is required",
domain: z.string({ required_error: 'Domain is required' }).min(1, {
message: 'Domain is required',
}),
});
@@ -58,8 +58,8 @@ export default function AddDomain() {
const domainForm = useForm<z.infer<typeof domainSchema>>({
resolver: zodResolver(domainSchema),
defaultValues: {
region: "",
domain: "",
region: '',
domain: '',
},
});
@@ -74,16 +74,16 @@ export default function AddDomain() {
async function onDomainAdd(values: z.infer<typeof domainSchema>) {
const domain = tldts.getDomain(values.domain);
if (!domain) {
domainForm.setError("domain", {
message: "Invalid domain",
domainForm.setError('domain', {
message: 'Invalid domain',
});
return;
}
if (!values.region && !singleRegion) {
domainForm.setError("region", {
message: "Region is required",
domainForm.setError('region', {
message: 'Region is required',
});
return;
}
@@ -96,7 +96,7 @@ export default function AddDomain() {
addDomainMutation.mutate(
{
name: values.domain,
region: singleRegion ?? values.region ?? "",
region: singleRegion ?? values.region ?? '',
},
{
onSuccess: async (data) => {
@@ -107,7 +107,7 @@ export default function AddDomain() {
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -127,7 +127,7 @@ export default function AddDomain() {
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-1" />
<Plus className="mr-1 h-4 w-4" />
Add domain
</Button>
</DialogTrigger>
@@ -155,7 +155,7 @@ export default function AddDomain() {
) : (
<FormDescription>
Use subdomains to separate transactional and marketing
emails.{" "}
emails.{' '}
</FormDescription>
)}
</FormItem>
@@ -191,7 +191,7 @@ export default function AddDomain() {
<FormMessage />
) : (
<FormDescription>
Select the region from where the email is sent{" "}
Select the region from where the email is sent{' '}
</FormDescription>
)}
</FormItem>
@@ -201,13 +201,13 @@ export default function AddDomain() {
<div className="flex justify-end">
<Button
className=" w-[100px]"
className="w-[100px]"
type="submit"
disabled={
addDomainMutation.isPending || limitsQuery.isLoading
}
>
{addDomainMutation.isPending ? "Adding..." : "Add"}
{addDomainMutation.isPending ? 'Adding...' : 'Add'}
</Button>
</div>
</form>

View File

@@ -1,30 +1,30 @@
import { DomainStatus } from "@prisma/client";
import { DomainStatus } from '@prisma/client';
export const DomainStatusBadge: React.FC<{ status: DomainStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray/10 text-gray border-gray/10"; // Default color
let badgeColor = 'bg-gray/10 text-gray border-gray/10'; // Default color
switch (status) {
case DomainStatus.SUCCESS:
badgeColor = "bg-green/15 text-green border border-green/25";
badgeColor = 'bg-green/15 text-green border border-green/25';
break;
case DomainStatus.FAILED:
badgeColor = "bg-red/10 text-red border border-red/10";
badgeColor = 'bg-red/10 text-red border border-red/10';
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
badgeColor = 'bg-yellow/20 text-yellow border border-yellow/10';
break;
default:
badgeColor = "bg-gray/70 text-gray border border-gray/20";
badgeColor = 'bg-gray/70 text-gray border border-gray/20';
}
return (
<div
className={` text-center w-[120px] capitalize rounded-md py-1 justify-center flex items-center ${badgeColor}`}
className={`flex w-[120px] items-center justify-center rounded-md py-1 text-center capitalize ${badgeColor}`}
>
<span className="text-xs">
{status === "SUCCESS" ? "Verified" : status.toLowerCase()}
{status === 'SUCCESS' ? 'Verified' : status.toLowerCase()}
</span>
</div>
);

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { Domain } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import { Switch } from "@usesend/ui/src/switch";
import { api } from "~/trpc/react";
import React from "react";
import { StatusIndicator } from "./status-indicator";
import { DomainStatusBadge } from "./domain-badge";
import Spinner from "@usesend/ui/src/spinner";
import { Domain } from '@prisma/client';
import { formatDistanceToNow } from 'date-fns';
import Link from 'next/link';
import { Switch } from '@usesend/ui/src/switch';
import { api } from '~/trpc/react';
import React from 'react';
import { StatusIndicator } from './status-indicator';
import { DomainStatusBadge } from './domain-badge';
import Spinner from '@usesend/ui/src/spinner';
export default function DomainsList() {
const domainsQuery = api.domain.domains.useQuery();
@@ -17,9 +17,9 @@ export default function DomainsList() {
<div className="mt-10">
<div className="flex flex-col gap-6">
{domainsQuery.isLoading ? (
<div className="flex justify-center mt-10">
<div className="mt-10 flex justify-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</div>
@@ -28,7 +28,7 @@ export default function DomainsList() {
<DomainItem key={domain.id} domain={domain} />
))
) : (
<div className="text-center mt-20">No domains Added</div>
<div className="mt-20 text-center">No domains Added</div>
)}
</div>
</div>
@@ -40,7 +40,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
const utils = api.useUtils();
const [clickTracking, setClickTracking] = React.useState(
domain.clickTracking
domain.clickTracking,
);
const [openTracking, setOpenTracking] = React.useState(domain.openTracking);
@@ -52,7 +52,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
onSuccess: () => {
utils.domain.domains.invalidate();
},
}
},
);
}
@@ -64,19 +64,19 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
onSuccess: () => {
utils.domain.domains.invalidate();
},
}
},
);
}
return (
<div key={domain.id}>
<div className=" pr-8 border rounded-lg flex items-stretch shadow">
<div className="flex items-stretch rounded-lg border pr-8 shadow">
<StatusIndicator status={domain.status} />
<div className="flex justify-between w-full pl-8 py-4">
<div className="flex flex-col gap-4 w-1/5">
<div className="flex w-full justify-between py-4 pl-8">
<div className="flex w-1/5 flex-col gap-4">
<Link
href={`/domains/${domain.id}`}
className="text-lg font-medium underline underline-offset-4 decoration-dashed"
className="text-lg font-medium underline decoration-dashed underline-offset-4"
>
{domain.name}
</Link>
@@ -85,7 +85,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
<div className="flex flex-col gap-4">
<div>
<p className="text-sm text-muted-foreground">Created at</p>
<p className="text-muted-foreground text-sm">Created at</p>
<p className="text-sm">
{formatDistanceToNow(new Date(domain.createdAt), {
addSuffix: true,
@@ -93,13 +93,13 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Region</p>
<p className="text-muted-foreground text-sm">Region</p>
<p className="text-sm flex items-center gap-2">{domain.region}</p>
<p className="flex items-center gap-2 text-sm">{domain.region}</p>
</div>
</div>
<div className="flex flex-col gap-6">
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<p className="text-sm">Click tracking</p>
<Switch
checked={clickTracking}
@@ -107,7 +107,7 @@ const DomainItem: React.FC<{ domain: Domain }> = ({ domain }) => {
className="data-[state=checked]:bg-success"
/>
</div>
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<p className="text-sm">Open tracking</p>
<Switch
checked={openTracking}

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import DomainsList from "./domain-list";
import AddDomain from "./add-domain";
import { H1 } from "@usesend/ui";
import DomainsList from './domain-list';
import AddDomain from './add-domain';
import { H1 } from '@usesend/ui';
export default function DomainsPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Domains</H1>
<AddDomain />
</div>

View File

@@ -1,26 +1,26 @@
import { DomainStatus } from "@prisma/client";
import { DomainStatus } from '@prisma/client';
export const StatusIndicator: React.FC<{ status: DomainStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray"; // Default color
let badgeColor = 'bg-gray'; // Default color
switch (status) {
case DomainStatus.NOT_STARTED:
badgeColor = "bg-gray";
badgeColor = 'bg-gray';
break;
case DomainStatus.SUCCESS:
badgeColor = "bg-green";
badgeColor = 'bg-green';
break;
case DomainStatus.FAILED:
badgeColor = "bg-red";
badgeColor = 'bg-red';
break;
case DomainStatus.TEMPORARY_FAILURE:
case DomainStatus.PENDING:
badgeColor = "bg-yellow";
badgeColor = 'bg-yellow';
break;
default:
badgeColor = "bg-gray";
badgeColor = 'bg-gray';
}
return <div className={` w-[2px] ${badgeColor} my-1.5 rounded-full`}></div>;
return <div className={`w-[2px] ${badgeColor} my-1.5 rounded-full`}></div>;
};

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
@@ -9,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import React, { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import React, { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -25,7 +25,7 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
const cancelSchema = z.object({
confirmation: z.string(),
@@ -44,9 +44,9 @@ export const CancelEmail: React.FC<{
});
async function onEmailCancel(values: z.infer<typeof cancelSchema>) {
if (values.confirmation !== "cancel") {
cancelForm.setError("confirmation", {
message: "Confirmation does not match",
if (values.confirmation !== 'cancel') {
cancelForm.setError('confirmation', {
message: 'Confirmation does not match',
});
return;
}
@@ -68,7 +68,7 @@ export const CancelEmail: React.FC<{
);
}
const confirmation = cancelForm.watch("confirmation");
const confirmation = cancelForm.watch('confirmation');
return (
<Dialog
@@ -77,7 +77,7 @@ export const CancelEmail: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red" />
<Trash2 className="text-red h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
@@ -118,12 +118,12 @@ export const CancelEmail: React.FC<{
type="submit"
variant="destructive"
disabled={
cancelEmailMutation.isPending || confirmation !== "cancel"
cancelEmailMutation.isPending || confirmation !== 'cancel'
}
>
{cancelEmailMutation.isPending
? "Cancelling..."
: "Cancel Email"}
? 'Cancelling...'
: 'Cancel Email'}
</Button>
</div>
</form>

View File

@@ -1,27 +1,27 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import * as chrono from "chrono-node";
import { api } from "~/trpc/react";
import { useRef, useState } from "react";
import { Edit3 } from "lucide-react";
import { useRouter } from "next/navigation";
import { toast } from "@usesend/ui/src/toaster";
} from '@usesend/ui/src/dialog';
import * as chrono from 'chrono-node';
import { api } from '~/trpc/react';
import { useRef, useState } from 'react';
import { Edit3 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from '@usesend/ui/src/toaster';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSubContent,
DropdownMenuTrigger,
} from "@usesend/ui/src/dropdown-menu";
} from '@usesend/ui/src/dropdown-menu';
import {
Command,
CommandDialog,
@@ -31,7 +31,7 @@ import {
CommandItem,
CommandList,
CommandSeparator,
} from "@usesend/ui/src/command";
} from '@usesend/ui/src/command';
export const EditSchedule: React.FC<{
emailId: string;
@@ -39,9 +39,9 @@ export const EditSchedule: React.FC<{
}> = ({ emailId, scheduledAt }) => {
const [open, setOpen] = useState(false);
const [openSuggestions, setOpenSuggestions] = useState(true);
const [scheduleInput, setScheduleInput] = useState(scheduledAt || "");
const [scheduleInput, setScheduleInput] = useState(scheduledAt || '');
const [scheduledAtTime, setScheduledAtTime] = useState<Date | null>(
scheduledAt ? new Date(scheduledAt) : null
scheduledAt ? new Date(scheduledAt) : null,
);
const updateEmailScheduledAtMutation =
api.email.updateEmailScheduledAt.useMutation();
@@ -53,7 +53,7 @@ export const EditSchedule: React.FC<{
const handleScheduleUpdate = () => {
const parsedDate = chrono.parseDate(scheduleInput);
if (!parsedDate) {
toast.error("Invalid date and time");
toast.error('Invalid date and time');
return;
}
@@ -66,12 +66,12 @@ export const EditSchedule: React.FC<{
onSuccess: () => {
utils.email.getEmail.invalidate({ id: emailId });
setOpen(false);
toast.success("Email schedule updated successfully");
toast.success('Email schedule updated successfully');
},
onError: (error) => {
toast.error(error.message);
},
}
},
);
};
@@ -100,7 +100,7 @@ export const EditSchedule: React.FC<{
<div className="py-2">
<div className="space-y-4">
<div>
<label htmlFor="scheduleInput" className="block mb-2">
<label htmlFor="scheduleInput" className="mb-2 block">
Schedule at
</label>
{/* <Input
@@ -155,8 +155,8 @@ export const EditSchedule: React.FC<{
disabled={updateEmailScheduledAtMutation.isPending}
>
{updateEmailScheduledAtMutation.isPending
? "Updating..."
: "Update"}
? 'Updating...'
: 'Update'}
</Button>
</div>
</div>

View File

@@ -1,42 +1,42 @@
"use client";
'use client';
import { UAParser } from "ua-parser-js";
import { api } from "~/trpc/react";
import { Separator } from "@usesend/ui/src/separator";
import { EmailStatusBadge, EmailStatusIcon } from "./email-status-badge";
import { formatDate } from "date-fns";
import { motion } from "framer-motion";
import { EmailStatus } from "@prisma/client";
import { JsonValue } from "@prisma/client/runtime/library";
import { UAParser } from 'ua-parser-js';
import { api } from '~/trpc/react';
import { Separator } from '@usesend/ui/src/separator';
import { EmailStatusBadge, EmailStatusIcon } from './email-status-badge';
import { formatDate } from 'date-fns';
import { motion } from 'framer-motion';
import { EmailStatus } from '@prisma/client';
import { JsonValue } from '@prisma/client/runtime/library';
import {
SesBounce,
SesClick,
SesComplaint,
SesDeliveryDelay,
SesOpen,
} from "~/types/aws-types";
} from '~/types/aws-types';
import {
BOUNCE_ERROR_MESSAGES,
COMPLAINT_ERROR_MESSAGES,
DELIVERY_DELAY_ERRORS,
} from "~/lib/constants/ses-errors";
import CancelEmail from "./cancel-email";
import { useEffect } from "react";
import { useState } from "react";
} from '~/lib/constants/ses-errors';
import CancelEmail from './cancel-email';
import { useEffect } from 'react';
import { useState } from 'react';
export default function EmailDetails({ emailId }: { emailId: string }) {
const emailQuery = api.email.getEmail.useQuery({ id: emailId });
return (
<div className="h-full overflow-auto px-4 no-scrollbar">
<div className="flex justify-between items-center">
<div className="flex gap-4 items-center">
<div className="no-scrollbar h-full overflow-auto px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="font-bold">{emailQuery.data?.to}</h1>
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? "SENT"} />
<EmailStatusBadge status={emailQuery.data?.latestStatus ?? 'SENT'} />
</div>
</div>
<div className="flex flex-col mt-8 items-start gap-8">
<div className="p-2 rounded-lg border flex flex-col gap-2 w-full shadow">
<div className="mt-8 flex flex-col items-start gap-8">
<div className="flex w-full flex-col gap-2 rounded-lg border p-2 shadow">
{/* <div className="flex gap-2">
<span className="w-[100px] text-muted-foreground text-sm">
From
@@ -59,23 +59,23 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
{/* <div className=" text-[15px] font-medium">
{emailQuery.data?.to}
</div> */}
<div className=" text-sm">Subject: {emailQuery.data?.subject}</div>
<div className="text-sm">Subject: {emailQuery.data?.subject}</div>
<div className="text-muted-foreground text-xs">
From: {emailQuery.data?.from}
</div>
</div>
{emailQuery.data?.latestStatus === "SCHEDULED" &&
{emailQuery.data?.latestStatus === 'SCHEDULED' &&
emailQuery.data?.scheduledAt ? (
<>
<Separator />
<div className="flex gap-2 items-center px-4">
<span className="w-[100px] text-muted-foreground text-sm ">
<div className="flex items-center gap-2 px-4">
<span className="text-muted-foreground w-[100px] text-sm">
Scheduled at
</span>
<span className="text-sm">
{formatDate(
emailQuery.data?.scheduledAt,
"MMM dd'th', hh:mm a"
"MMM dd'th', hh:mm a",
)}
</span>
<div className="ml-4">
@@ -90,32 +90,32 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.3 }}
>
<EmailPreview html={emailQuery.data?.html ?? ""} />
<EmailPreview html={emailQuery.data?.html ?? ''} />
</motion.div>
</div>
{emailQuery.data?.latestStatus !== "SCHEDULED" ? (
<div className=" border rounded-lg w-full shadow mb-2 ">
<div className=" p-4 flex flex-col gap-8 w-full">
{emailQuery.data?.latestStatus !== 'SCHEDULED' ? (
<div className="mb-2 w-full rounded-lg border shadow">
<div className="flex w-full flex-col gap-8 p-4">
<div className="font-medium">Events History</div>
<div className="flex items-stretch px-4 w-full">
<div className="border-r border-gray-300 dark:border-gray-700 border-dashed" />
<div className="flex flex-col gap-12 w-full">
<div className="flex w-full items-stretch px-4">
<div className="border-r border-dashed border-gray-300 dark:border-gray-700" />
<div className="flex w-full flex-col gap-12">
{emailQuery.data?.emailEvents.map((evt) => (
<div
key={evt.status}
className="flex gap-5 items-start w-full"
className="flex w-full items-start gap-5"
>
<div className=" -ml-2.5">
<div className="-ml-2.5">
<EmailStatusIcon status={evt.status} />
</div>
<div className="-mt-[0.125rem] w-full">
<div className=" capitalize font-medium">
<div className="font-medium capitalize">
<EmailStatusBadge status={evt.status} />
</div>
<div className="text-xs text-muted-foreground mt-2">
{formatDate(evt.createdAt, "MMM dd, hh:mm a")}
<div className="text-muted-foreground mt-2 text-xs">
{formatDate(evt.createdAt, 'MMM dd, hh:mm a')}
</div>
<div className="mt-1 text-foreground/80">
<div className="text-foreground/80 mt-1">
<EmailStatusText
status={evt.status}
data={evt.data}
@@ -147,14 +147,14 @@ const EmailPreview = ({ html }: { html: string }) => {
if (!show) {
return (
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t"></div>
<div className="h-[350px] overflow-visible rounded border-t dark:bg-slate-200"></div>
);
}
return (
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t">
<div className="h-[350px] overflow-visible rounded border-t dark:bg-slate-200">
<iframe
className="w-full h-full"
className="h-full w-full"
srcDoc={html}
sandbox="allow-same-origin"
/>
@@ -169,106 +169,106 @@ const EmailStatusText = ({
status: EmailStatus;
data: JsonValue;
}) => {
if (status === "SENT") {
if (status === 'SENT') {
return (
<div>
We received your request and sent the email to recipient's server.
</div>
);
} else if (status === "DELIVERED") {
} else if (status === 'DELIVERED') {
return <div>Mail is successfully delivered to the recipient.</div>;
} else if (status === "DELIVERY_DELAYED") {
} else if (status === 'DELIVERY_DELAYED') {
const _errorData = data as unknown as SesDeliveryDelay;
const errorMessage = DELIVERY_DELAY_ERRORS[_errorData.delayType];
return <div>{errorMessage}</div>;
} else if (status === "BOUNCED") {
} else if (status === 'BOUNCED') {
const _errorData = data as unknown as SesBounce;
_errorData.bounceType;
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex w-full flex-col gap-4">
<p>{getErrorMessage(_errorData)}</p>
<div className="rounded-xl p-4 bg-muted/30 flex flex-col gap-4">
<div className="flex gap-2 w-full">
<div className="bg-muted/30 flex flex-col gap-4 rounded-xl p-4">
<div className="flex w-full gap-2">
<div className="w-1/2">
<p className="text-sm text-muted-foreground">Type</p>
<p className="text-muted-foreground text-sm">Type</p>
<p>{_errorData.bounceType}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Sub Type</p>
<p className="text-muted-foreground text-sm">Sub Type</p>
<p>{_errorData.bounceSubType}</p>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">SMTP response</p>
<p className="text-muted-foreground text-sm">SMTP response</p>
<p>{_errorData.bouncedRecipients[0]?.diagnosticCode}</p>
</div>
</div>
</div>
);
} else if (status === "FAILED") {
} else if (status === 'FAILED') {
const _errorData = data as unknown as { error: string };
return <div>{_errorData.error}</div>;
} else if (status === "OPENED") {
} else if (status === 'OPENED') {
const _data = data as unknown as SesOpen;
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="w-full rounded-xl p-4 bg-muted/30 mt-4">
<div className="flex w-full ">
<div className="bg-muted/30 mt-4 w-full rounded-xl p-4">
<div className="flex w-full">
{userAgent.os.name ? (
<div className="w-1/2">
<p className="text-sm text-muted-foreground">OS</p>
<p className="text-muted-foreground text-sm">OS</p>
<p>{userAgent.os.name}</p>
</div>
) : null}
{userAgent.browser.name ? (
<div>
<p className="text-sm text-muted-foreground">Browser</p>
<p className="text-muted-foreground text-sm">Browser</p>
<p>{userAgent.browser.name}</p>
</div>
) : null}
</div>
</div>
);
} else if (status === "CLICKED") {
} else if (status === 'CLICKED') {
const _data = data as unknown as SesClick;
const userAgent = getUserAgent(_data.userAgent);
return (
<div className="w-full mt-4 flex flex-col gap-4 rounded-xl p-4 bg-muted/30">
<div className="flex w-full ">
<div className="bg-muted/30 mt-4 flex w-full flex-col gap-4 rounded-xl p-4">
<div className="flex w-full">
{userAgent.os.name ? (
<div className="w-1/2">
<p className="text-sm text-muted-foreground">OS </p>
<p className="text-muted-foreground text-sm">OS </p>
<p>{userAgent.os.name}</p>
</div>
) : null}
{userAgent.browser.name ? (
<div>
<p className="text-sm text-muted-foreground">Browser </p>
<p className="text-muted-foreground text-sm">Browser </p>
<p>{userAgent.browser.name}</p>
</div>
) : null}
</div>
<div className="w-full">
<p className="text-sm text-muted-foreground">URL</p>
<p className="text-muted-foreground text-sm">URL</p>
<p>{_data.link}</p>
</div>
</div>
);
} else if (status === "COMPLAINED") {
} else if (status === 'COMPLAINED') {
const _errorData = data as unknown as SesComplaint;
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex w-full flex-col gap-4">
<p>{getComplaintMessage(_errorData.complaintFeedbackType)}</p>
</div>
);
} else if (status === "CANCELLED") {
} else if (status === 'CANCELLED') {
return <div>This scheduled email was cancelled</div>;
} else if (status === "SUPPRESSED") {
} else if (status === 'SUPPRESSED') {
return (
<div>
This email was suppressed because this email is previously either
@@ -281,24 +281,24 @@ const EmailStatusText = ({
};
const getErrorMessage = (data: SesBounce) => {
if (data.bounceType === "Permanent") {
if (data.bounceType === 'Permanent') {
return BOUNCE_ERROR_MESSAGES[data.bounceType][
data.bounceSubType as
| "General"
| "NoEmail"
| "Suppressed"
| "OnAccountSuppressionList"
| 'General'
| 'NoEmail'
| 'Suppressed'
| 'OnAccountSuppressionList'
];
} else if (data.bounceType === "Transient") {
} else if (data.bounceType === 'Transient') {
return BOUNCE_ERROR_MESSAGES[data.bounceType][
data.bounceSubType as
| "General"
| "MailboxFull"
| "MessageTooLarge"
| "ContentRejected"
| "AttachmentRejected"
| 'General'
| 'MailboxFull'
| 'MessageTooLarge'
| 'ContentRejected'
| 'AttachmentRejected'
];
} else if (data.bounceType === "Undetermined") {
} else if (data.bounceType === 'Undetermined') {
return BOUNCE_ERROR_MESSAGES.Undetermined;
}
};

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,8 +7,8 @@ import {
TableHead,
TableBody,
TableCell,
} from "@usesend/ui/src/table";
import { api } from "~/trpc/react";
} from '@usesend/ui/src/table';
import { api } from '~/trpc/react';
import {
Mail,
MailCheck,
@@ -17,51 +17,51 @@ import {
MailWarning,
MailX,
Download,
} from "lucide-react";
import { formatDate, formatDistanceToNow } from "date-fns";
import { EmailStatus } from "@prisma/client";
import { EmailStatusBadge } from "./email-status-badge";
import EmailDetails from "./email-details";
import dynamic from "next/dynamic";
import { useUrlState } from "~/hooks/useUrlState";
import { Button } from "@usesend/ui/src/button";
} from 'lucide-react';
import { formatDate, formatDistanceToNow } from 'date-fns';
import { EmailStatus } from '@prisma/client';
import { EmailStatusBadge } from './email-status-badge';
import EmailDetails from './email-details';
import dynamic from 'next/dynamic';
import { useUrlState } from '~/hooks/useUrlState';
import { Button } from '@usesend/ui/src/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@usesend/ui/src/select";
import Spinner from "@usesend/ui/src/spinner";
} from '@usesend/ui/src/select';
import Spinner from '@usesend/ui/src/spinner';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
import { Input } from "@usesend/ui/src/input";
import { DEFAULT_QUERY_LIMIT } from "~/lib/constants";
import { useDebouncedCallback } from "use-debounce";
import { useState } from "react";
import { SheetTitle, SheetDescription } from "@usesend/ui/src/sheet";
} from '@usesend/ui/src/tooltip';
import { Input } from '@usesend/ui/src/input';
import { DEFAULT_QUERY_LIMIT } from '~/lib/constants';
import { useDebouncedCallback } from 'use-debounce';
import { useState } from 'react';
import { SheetTitle, SheetDescription } from '@usesend/ui/src/sheet';
/* Stupid hydrating error. And I so stupid to understand the stupid NextJS docs */
const DynamicSheetWithNoSSR = dynamic(
() => import("@usesend/ui/src/sheet").then((mod) => mod.Sheet),
() => import('@usesend/ui/src/sheet').then((mod) => mod.Sheet),
{ ssr: false },
);
const DynamicSheetContentWithNoSSR = dynamic(
() => import("@usesend/ui/src/sheet").then((mod) => mod.SheetContent),
() => import('@usesend/ui/src/sheet').then((mod) => mod.SheetContent),
{ ssr: false },
);
export default function EmailsList() {
const [selectedEmail, setSelectedEmail] = useUrlState("emailId");
const [page, setPage] = useUrlState("page", "1");
const [status, setStatus] = useUrlState("status");
const [search, setSearch] = useUrlState("search");
const [domain, setDomain] = useUrlState("domain");
const [apiKey, setApiKey] = useUrlState("apikey");
const [selectedEmail, setSelectedEmail] = useUrlState('emailId');
const [page, setPage] = useUrlState('page', '1');
const [status, setStatus] = useUrlState('status');
const [search, setSearch] = useUrlState('search');
const [domain, setDomain] = useUrlState('domain');
const [apiKey, setApiKey] = useUrlState('apikey');
const pageNumber = Number(page);
const domainId = domain ? Number(domain) : undefined;
@@ -93,11 +93,11 @@ export default function EmailsList() {
};
const handleDomain = (val: string) => {
setDomain(val === "All Domains" ? null : val);
setDomain(val === 'All Domains' ? null : val);
};
const handleApiKey = (val: string) => {
setApiKey(val === "All API Keys" ? null : val);
setApiKey(val === 'All API Keys' ? null : val);
};
const handleSheetChange = (isOpen: boolean) => {
@@ -116,21 +116,21 @@ export default function EmailsList() {
if (!resp.data) return;
const escape = (val: unknown) => {
const s = String(val ?? "");
const s = String(val ?? '');
const startsRisky = /^\s*[=+\-@]/.test(s);
const safe = (startsRisky ? "'" : "") + s.replace(/"/g, '""');
const safe = (startsRisky ? "'" : '') + s.replace(/"/g, '""');
return /[",\r\n]/.test(safe) ? `"${safe}"` : safe;
};
const header = [
"To",
"Status",
"Subject",
"Sent At",
"Bounce Type",
"Bounce Subtype",
"Bounce Reason",
].join(",");
'To',
'Status',
'Subject',
'Sent At',
'Bounce Type',
'Bounce Subtype',
'Bounce Reason',
].join(',');
const rows = resp.data.map((e) =>
[
e.to,
@@ -142,45 +142,45 @@ export default function EmailsList() {
e.bounceReason,
]
.map(escape)
.join(","),
.join(','),
);
const csv = [header, ...rows].join("\n");
const csv = [header, ...rows].join('\n');
const blob = new Blob(["\uFEFF" + csv], {
type: "text/csv;charset=utf-8",
const blob = new Blob(['\uFEFF' + csv], {
type: 'text/csv;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const a = document.createElement('a');
a.href = url;
a.download = `emails-${new Date().toISOString().split("T")[0]}.csv`;
a.download = `emails-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Export failed", err);
console.error('Export failed', err);
}
};
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<Input
placeholder="Search by subject or email"
className="w-[350px] mr-4"
defaultValue={search ?? ""}
className="mr-4 w-[350px]"
defaultValue={search ?? ''}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<div className="flex justify-center items-center gap-x-3">
<div className="flex items-center justify-center gap-x-3">
<Select
value={apiKey ?? "All API Keys"}
value={apiKey ?? 'All API Keys'}
onValueChange={(val) => handleApiKey(val)}
>
<SelectTrigger className="w-[180px]">
{apiKey
? apiKeysQuery?.find((apikey) => apikey.id === Number(apiKey))
?.name
: "All API Keys"}
: 'All API Keys'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All API Keys">All API Keys</SelectItem>
@@ -193,16 +193,16 @@ export default function EmailsList() {
</SelectContent>
</Select>
<Select
value={domain ?? "All Domains"}
value={domain ?? 'All Domains'}
onValueChange={(val) => handleDomain(val)}
>
<SelectTrigger className="w-[180px]">
{domain
? domainsQuery?.find((d) => d.id === Number(domain))?.name
: "All Domains"}
: 'All Domains'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All Domains" className=" capitalize">
<SelectItem value="All Domains" className="capitalize">
All Domains
</SelectItem>
{domainsQuery &&
@@ -214,32 +214,32 @@ export default function EmailsList() {
</SelectContent>
</Select>
<Select
value={status ?? "All statuses"}
value={status ?? 'All statuses'}
onValueChange={(val) =>
setStatus(val === "All statuses" ? null : val)
setStatus(val === 'All statuses' ? null : val)
}
>
<SelectTrigger className="w-[180px] capitalize">
{status ? status.toLowerCase().replace("_", " ") : "All statuses"}
{status ? status.toLowerCase().replace('_', ' ') : 'All statuses'}
</SelectTrigger>
<SelectContent>
<SelectItem value="All statuses" className=" capitalize">
<SelectItem value="All statuses" className="capitalize">
All statuses
</SelectItem>
{Object.values([
"SENT",
"SCHEDULED",
"QUEUED",
"DELIVERED",
"BOUNCED",
"CLICKED",
"OPENED",
"DELIVERY_DELAYED",
"COMPLAINED",
"SUPPRESSED",
'SENT',
'SCHEDULED',
'QUEUED',
'DELIVERED',
'BOUNCED',
'CLICKED',
'OPENED',
'DELIVERY_DELAYED',
'COMPLAINED',
'SUPPRESSED',
]).map((status) => (
<SelectItem key={status} value={status} className=" capitalize">
{status.toLowerCase().replace("_", " ")}
<SelectItem key={status} value={status} className="capitalize">
{status.toLowerCase().replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
@@ -249,7 +249,7 @@ export default function EmailsList() {
onClick={handleExport}
disabled={exportQuery.isFetching}
>
<Download className="h-4 w-4 mr-2" />
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
@@ -257,11 +257,11 @@ export default function EmailsList() {
<div className="flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted dark:bg-muted/70">
<TableRow className="bg-muted dark:bg-muted/70">
<TableHead className="rounded-tl-xl">To</TableHead>
<TableHead>Status</TableHead>
<TableHead>Subject</TableHead>
<TableHead className="text-right rounded-tr-xl">
<TableHead className="rounded-tr-xl text-right">
Sent at
</TableHead>
</TableRow>
@@ -269,9 +269,9 @@ export default function EmailsList() {
<TableBody>
{emailsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -281,25 +281,25 @@ export default function EmailsList() {
<TableRow
key={email.id}
onClick={() => handleSelectEmail(email.id)}
className=" cursor-pointer"
className="cursor-pointer"
>
<TableCell className="font-medium">
<div className="flex gap-4 items-center">
<div className="flex items-center gap-4">
{/* <EmailIcon status={email.latestStatus ?? "Sent"} /> */}
<p> {email.to}</p>
</div>
</TableCell>
<TableCell>
{email.latestStatus === "SCHEDULED" && email.scheduledAt ? (
{email.latestStatus === 'SCHEDULED' && email.scheduledAt ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<EmailStatusBadge
status={email.latestStatus ?? "Sent"}
status={email.latestStatus ?? 'Sent'}
/>
</TooltipTrigger>
<TooltipContent>
Scheduled at{" "}
Scheduled at{' '}
{formatDate(
email.scheduledAt,
"MMM dd'th', hh:mm a",
@@ -308,25 +308,25 @@ export default function EmailsList() {
</Tooltip>
</TooltipProvider>
) : (
<EmailStatusBadge status={email.latestStatus ?? "Sent"} />
<EmailStatusBadge status={email.latestStatus ?? 'Sent'} />
)}
</TableCell>
<TableCell className="">
<div className=" max-w-xs truncate">{email.subject}</div>
<div className="max-w-xs truncate">{email.subject}</div>
</TableCell>
<TableCell className="text-right">
{email.latestStatus !== "SCHEDULED"
{email.latestStatus !== 'SCHEDULED'
? formatDate(
email.scheduledAt ?? email.createdAt,
"MMM do, hh:mm a",
'MMM do, hh:mm a',
)
: "--"}
: '--'}
</TableCell>
</TableRow>
))
) : (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No emails found
</TableCell>
</TableRow>
@@ -338,7 +338,7 @@ export default function EmailsList() {
open={!!selectedEmail}
onOpenChange={handleSheetChange}
>
<DynamicSheetContentWithNoSSR className="sm:max-w-3xl overflow-y-auto no-scrollbar">
<DynamicSheetContentWithNoSSR className="no-scrollbar overflow-y-auto sm:max-w-3xl">
<SheetTitle className="sr-only">Email Details</SheetTitle>
<SheetDescription className="sr-only">
Detailed view of the selected email.
@@ -347,7 +347,7 @@ export default function EmailsList() {
</DynamicSheetContentWithNoSSR>
</DynamicSheetWithNoSSR>
</div>
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage((pageNumber - 1).toString())}
@@ -369,48 +369,48 @@ export default function EmailsList() {
const EmailIcon: React.FC<{ status: EmailStatus }> = ({ status }) => {
switch (status) {
case "SENT":
case 'SENT':
return (
// <div className="border border-gray-400/60 p-2 rounded-lg bg-gray-400/10">
<Mail className="w-6 h-6 text-gray" />
<Mail className="text-gray h-6 w-6" />
// </div>
);
case "DELIVERED":
case 'DELIVERED':
return (
// <div className="border border-emerald-600/60 p-2 rounded-lg bg-emerald-500/10">
<MailCheck className="w-6 h-6 text-green" />
<MailCheck className="text-green h-6 w-6" />
// </div>
);
case "BOUNCED":
case "FAILED":
case 'BOUNCED':
case 'FAILED':
return (
// <div className="border border-red-600/60 p-2 rounded-lg bg-red-500/10">
<MailX className="w-6 h-6 text-red" />
<MailX className="text-red h-6 w-6" />
// </div>
);
case "CLICKED":
case 'CLICKED':
return (
// <div className="border border-cyan-600/60 p-2 rounded-lg bg-cyan-500/10">
<MailSearch className="w-6 h-6 text-blue" />
<MailSearch className="text-blue h-6 w-6" />
// </div>
);
case "OPENED":
case 'OPENED':
return (
// <div className="border border-indigo-600/60 p-2 rounded-lg bg-indigo-500/10">
<MailOpen className="w-6 h-6 text-purple" />
<MailOpen className="text-purple h-6 w-6" />
// </div>
);
case "DELIVERY_DELAYED":
case "COMPLAINED":
case 'DELIVERY_DELAYED':
case 'COMPLAINED':
return (
// <div className="border border-yellow-600/60 p-2 rounded-lg bg-yellow-500/10">
<MailWarning className="w-6 h-6 text-yellow" />
<MailWarning className="text-yellow h-6 w-6" />
// </div>
);
default:
return (
// <div className="border border-gray-400/60 p-2 rounded-lg">
<Mail className="w-6 h-6" />
<Mail className="h-6 w-6" />
// </div>
);
}

View File

@@ -1,39 +1,39 @@
import { EmailStatus } from "@prisma/client";
import { EmailStatus } from '@prisma/client';
export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
status,
}) => {
let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
let badgeColor = 'bg-gray-700/10 text-gray-400 border border-gray-400/10'; // Default color
switch (status) {
case "DELIVERED":
badgeColor = "bg-green/15 text-green border border-green/20";
case 'DELIVERED':
badgeColor = 'bg-green/15 text-green border border-green/20';
break;
case "BOUNCED":
case "FAILED":
badgeColor = "bg-red/15 text-red border border-red/20";
case 'BOUNCED':
case 'FAILED':
badgeColor = 'bg-red/15 text-red border border-red/20';
break;
case "CLICKED":
badgeColor = "bg-blue/15 text-blue border border-blue/20";
case 'CLICKED':
badgeColor = 'bg-blue/15 text-blue border border-blue/20';
break;
case "OPENED":
badgeColor = "bg-purple/15 text-purple border border-purple/20";
case 'OPENED':
badgeColor = 'bg-purple/15 text-purple border border-purple/20';
break;
case "COMPLAINED":
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
case 'COMPLAINED':
badgeColor = 'bg-yellow/15 text-yellow border border-yellow/20';
break;
case "DELIVERY_DELAYED":
badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
case 'DELIVERY_DELAYED':
badgeColor = 'bg-yellow/15 text-yellow border border-yellow/20';
break;
default:
badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10"; // Default color
badgeColor = 'bg-gray-700/10 text-gray-400 border border-gray-400/10'; // Default color
}
return (
<div
className={` text-center w-[130px] rounded capitalize py-1 text-xs ${badgeColor}`}
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${badgeColor}`}
>
{status.toLowerCase().split("_").join(" ")}
{status.toLowerCase().split('_').join(' ')}
</div>
);
};
@@ -41,44 +41,44 @@ export const EmailStatusBadge: React.FC<{ status: EmailStatus }> = ({
export const EmailStatusIcon: React.FC<{ status: EmailStatus }> = ({
status,
}) => {
let outsideColor = "bg-gray/30"; // Default
let insideColor = "bg-gray"; // Default
let outsideColor = 'bg-gray/30'; // Default
let insideColor = 'bg-gray'; // Default
switch (status) {
case "DELIVERED":
outsideColor = "bg-green/30";
insideColor = "bg-green";
case 'DELIVERED':
outsideColor = 'bg-green/30';
insideColor = 'bg-green';
break;
case "BOUNCED":
case "FAILED":
outsideColor = "bg-red/30";
insideColor = "bg-red";
case 'BOUNCED':
case 'FAILED':
outsideColor = 'bg-red/30';
insideColor = 'bg-red';
break;
case "CLICKED":
outsideColor = "bg-blue/30";
insideColor = "bg-blue";
case 'CLICKED':
outsideColor = 'bg-blue/30';
insideColor = 'bg-blue';
break;
case "OPENED":
outsideColor = "bg-purple/30";
insideColor = "bg-purple";
case 'OPENED':
outsideColor = 'bg-purple/30';
insideColor = 'bg-purple';
break;
case "DELIVERY_DELAYED":
outsideColor = "bg-yellow/30";
insideColor = "bg-yellow";
case 'DELIVERY_DELAYED':
outsideColor = 'bg-yellow/30';
insideColor = 'bg-yellow';
break;
case "COMPLAINED":
outsideColor = "bg-yellow/30";
insideColor = "bg-yellow";
case 'COMPLAINED':
outsideColor = 'bg-yellow/30';
insideColor = 'bg-yellow';
break;
default:
// Using the default values defined above
outsideColor = "bg-gray/30";
insideColor = "bg-gray";
outsideColor = 'bg-gray/30';
insideColor = 'bg-gray';
}
return (
<div
className={`flex justify-center items-center p-1.5 ${outsideColor} rounded-full`}
className={`flex items-center justify-center p-1.5 ${outsideColor} rounded-full`}
>
<div className={`h-2 w-2 rounded-full ${insideColor}`}></div>
</div>

View File

@@ -1,12 +1,12 @@
"use client";
'use client';
import EmailList from "./email-list";
import { H1 } from "@usesend/ui";
import EmailList from './email-list';
import { H1 } from '@usesend/ui';
export default function EmailsPage() {
return (
<div>
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<H1>Emails</H1>
</div>
<EmailList />

View File

@@ -1,8 +1,8 @@
import { DashboardProvider } from "~/providers/dashboard-provider";
import { NextAuthProvider } from "~/providers/next-auth";
import { DashboardLayout } from "./dasboard-layout";
import { DashboardProvider } from '~/providers/dashboard-provider';
import { NextAuthProvider } from '~/providers/next-auth';
import { DashboardLayout } from './dasboard-layout';
export const dynamic = "force-static";
export const dynamic = 'force-static';
export default function AuthenticatedDashboardLayout({
children,

View File

@@ -1,22 +1,22 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { CheckCircle2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { api } from "~/trpc/react";
import { H1 } from "@usesend/ui";
import { Button } from '@usesend/ui/src/button';
import Spinner from '@usesend/ui/src/spinner';
import { CheckCircle2 } from 'lucide-react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { api } from '~/trpc/react';
import { H1 } from '@usesend/ui';
export default function PaymentsPage() {
const searchParams = useSearchParams();
const success = searchParams.get("success");
const canceled = searchParams.get("canceled");
const success = searchParams.get('success');
const canceled = searchParams.get('canceled');
return (
<div className="container mx-auto py-10">
<H1>Payment {success ? "Success" : canceled ? "Canceled" : "Unknown"}</H1>
<H1>Payment {success ? 'Success' : canceled ? 'Canceled' : 'Unknown'}</H1>
{canceled ? (
<Link href="/settings/billing">
<Button>Go to billing</Button>
@@ -32,11 +32,11 @@ function VerifySuccess() {
refetchInterval: 3000,
});
if (teams?.[0]?.plan !== "FREE") {
if (teams?.[0]?.plan !== 'FREE') {
return (
<div>
<div className="flex gap-2 items-center">
<CheckCircle2 className="h-4 w-4 text-green flex-shrink-0" />
<div className="flex items-center gap-2">
<CheckCircle2 className="text-green h-4 w-4 flex-shrink-0" />
<p>Your account has been upgraded to the paid plan.</p>
</div>
<Link href="/settings/billing" className="mt-8">
@@ -47,9 +47,9 @@ function VerifySuccess() {
}
return (
<div className="flex gap-2 items-center">
<div className="flex items-center gap-2">
<Spinner
className="h-5 w-5 stroke-muted-foreground"
className="stroke-muted-foreground h-5 w-5"
innerSvgClass=" stroke-muted-foreground"
/>
<p className="text-muted-foreground">Verifying payment</p>

View File

@@ -1,14 +1,14 @@
"use client";
'use client';
import { useState } from "react";
import { Button } from "@usesend/ui/src/button";
import { Card } from "@usesend/ui/src/card";
import { Spinner } from "@usesend/ui/src/spinner";
import { format } from "date-fns";
import { useTeam } from "~/providers/team-context";
import { api } from "~/trpc/react";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
import { useState } from 'react';
import { Button } from '@usesend/ui/src/button';
import { Card } from '@usesend/ui/src/card';
import { Spinner } from '@usesend/ui/src/spinner';
import { format } from 'date-fns';
import { useTeam } from '~/providers/team-context';
import { api } from '~/trpc/react';
import { PlanDetails } from '~/components/payments/PlanDetails';
import { UpgradeButton } from '~/components/payments/UpgradeButton';
export default function SettingsPage() {
const { currentTeam, currentIsAdmin } = useTeam();
@@ -19,7 +19,7 @@ export default function SettingsPage() {
const { data: subscription } = api.billing.getSubscriptionDetails.useQuery();
const [isEditingEmail, setIsEditingEmail] = useState(false);
const [billingEmail, setBillingEmail] = useState(
currentTeam?.billingEmail || "",
currentTeam?.billingEmail || '',
);
const apiUtils = api.useUtils();
@@ -32,7 +32,7 @@ export default function SettingsPage() {
};
const handleEditEmail = () => {
setBillingEmail(currentTeam?.billingEmail || "");
setBillingEmail(currentTeam?.billingEmail || '');
setIsEditingEmail(true);
};
@@ -42,12 +42,12 @@ export default function SettingsPage() {
await apiUtils.team.getTeams.invalidate();
setIsEditingEmail(false);
} catch (error) {
console.error("Failed to update billing email:", error);
console.error('Failed to update billing email:', error);
}
};
const paymentMethod =
subscription?.paymentMethod && subscription.paymentMethod !== "null"
subscription?.paymentMethod && subscription.paymentMethod !== 'null'
? JSON.parse(subscription.paymentMethod)
: {};
@@ -57,27 +57,27 @@ export default function SettingsPage() {
if (!currentTeam?.plan) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-4 h-4" />
<div className="flex h-full items-center justify-center">
<Spinner className="h-4 w-4" />
</div>
);
}
return (
<div className="space-y-8">
<Card className=" rounded-xl mt-10 p-8 px-8">
<Card className="mt-10 rounded-xl p-8 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan !== "FREE" ? (
{currentTeam?.plan !== 'FREE' ? (
<Button
onClick={onManageClick}
className="mt-4 w-[120px]"
disabled={manageSessionUrl.isPending}
>
{manageSessionUrl.isPending ? (
<Spinner className="w-4 h-4" />
<Spinner className="h-4 w-4" />
) : (
"Manage"
'Manage'
)}
</Button>
) : (
@@ -85,44 +85,44 @@ export default function SettingsPage() {
)}
</div>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
<div className="mt-8 grid grid-cols-1 gap-8 md:grid-cols-2">
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Payment Method</div>
<div className="text-muted-foreground text-sm">Payment Method</div>
{subscription ? (
<div className="mt-2">
<div className="text-lg font-mono uppercase flex items-center gap-2">
<div className="flex items-center gap-2 font-mono text-lg uppercase">
{subscription.paymentMethod &&
subscription.paymentMethod !== "null" ? (
subscription.paymentMethod !== 'null' ? (
<>
<span>💳</span>
<span className="capitalize">
{paymentMethod?.card?.brand || ""} {" "}
{paymentMethod?.card?.last4 || ""}
{paymentMethod?.card?.brand || ''} {' '}
{paymentMethod?.card?.last4 || ''}
</span>
{paymentMethod?.card && (
<span className="text-sm text-muted-foreground lowercase">
<span className="text-muted-foreground text-sm lowercase">
(Expires: {paymentMethod.card.exp_month}/
{paymentMethod.card.exp_year})
</span>
)}
</>
) : (
"No Payment Method"
'No Payment Method'
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
Next billing date:{" "}
<div className="text-muted-foreground mt-1 text-sm">
Next billing date:{' '}
{subscription.currentPeriodEnd
? format(
new Date(subscription.currentPeriodEnd),
"MMM dd, yyyy",
'MMM dd, yyyy',
)
: "N/A"}
: 'N/A'}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground mt-2">
<div className="text-muted-foreground mt-2 text-sm">
No active subscription
</div>
)}
@@ -131,7 +131,7 @@ export default function SettingsPage() {
<Card className="p-6">
<div>
<div className="text-sm text-muted-foreground">Billing Email</div>
<div className="text-muted-foreground text-sm">Billing Email</div>
{isEditingEmail ? (
<div className="mt-2">
<div className="flex items-center gap-2">
@@ -139,7 +139,7 @@ export default function SettingsPage() {
type="email"
value={billingEmail}
onChange={(e) => setBillingEmail(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Enter billing email"
/>
<Button
@@ -148,9 +148,9 @@ export default function SettingsPage() {
size="sm"
>
{updateBillingEmailMutation.isPending ? (
<Spinner className="w-4 h-4" />
<Spinner className="h-4 w-4" />
) : (
"Save"
'Save'
)}
</Button>
<Button
@@ -166,7 +166,7 @@ export default function SettingsPage() {
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="font-mono">
{currentTeam?.billingEmail || "No billing email set"}
{currentTeam?.billingEmail || 'No billing email set'}
</div>
<Button onClick={handleEditEmail} variant="default" size="sm">
Edit

View File

@@ -1,10 +1,10 @@
"use client";
'use client';
import { useTeam } from "~/providers/team-context";
import { SettingsNavButton } from "../dev-settings/settings-nav-button";
import { isCloud } from "~/utils/common";
import { useTeam } from '~/providers/team-context';
import { SettingsNavButton } from '../dev-settings/settings-nav-button';
import { isCloud } from '~/utils/common';
export const dynamic = "force-static";
export const dynamic = 'force-static';
export default function ApiKeysPage({
children,
@@ -15,8 +15,8 @@ export default function ApiKeysPage({
return (
<div>
<h1 className="font-bold text-lg">Settings</h1>
<div className="flex gap-4 mt-4">
<h1 className="text-lg font-bold">Settings</h1>
<div className="mt-4 flex gap-4">
{isCloud() ? (
<SettingsNavButton href="/settings">Usage</SettingsNavButton>
) : null}

View File

@@ -1,16 +1,16 @@
"use client";
'use client';
import { isCloud } from "~/utils/common";
import UsagePage from "./usage/usage";
import InviteTeamMember from "./team/invite-team-member";
import TeamMembersList from "./team/team-members-list";
import { isCloud } from '~/utils/common';
import UsagePage from './usage/usage';
import InviteTeamMember from './team/invite-team-member';
import TeamMembersList from './team/team-members-list';
export default function SettingsPage() {
if (!isCloud()) {
return (
<div>
<div>
<div className="flex justify-end ">
<div className="flex justify-end">
<InviteTeamMember />
</div>
<TeamMembersList />

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
@@ -8,11 +8,11 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Trash2 } from "lucide-react";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Trash2 } from 'lucide-react';
export const DeleteTeamInvite: React.FC<{
invite: { id: string; email: string };
@@ -31,7 +31,7 @@ export const DeleteTeamInvite: React.FC<{
onSuccess: async () => {
utils.team.getTeamInvites.invalidate();
setOpen(false);
toast.success("Invite cancelled successfully");
toast.success('Invite cancelled successfully');
},
onError: async (error) => {
toast.error(error.message);
@@ -47,21 +47,21 @@ export const DeleteTeamInvite: React.FC<{
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red/80" />
<Trash2 className="text-red/80 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel Invite</DialogTitle>
<DialogDescription>
Are you sure you want to cancel the invite for{" "}
<span className="font-semibold text-foreground">
Are you sure you want to cancel the invite for{' '}
<span className="text-foreground font-semibold">
{invite.email}
</span>
?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4 mt-6">
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
@@ -8,12 +8,12 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
import { api } from "~/trpc/react";
import { useState } from "react";
import { toast } from "@usesend/ui/src/toaster";
import { Role } from "@prisma/client";
import { LogOut, Trash2 } from "lucide-react";
} from '@usesend/ui/src/dialog';
import { api } from '~/trpc/react';
import { useState } from 'react';
import { toast } from '@usesend/ui/src/toaster';
import { Role } from '@prisma/client';
import { LogOut, Trash2 } from 'lucide-react';
export const DeleteTeamMember: React.FC<{
teamUser: { userId: string; role: Role; email: string };
@@ -33,7 +33,7 @@ export const DeleteTeamMember: React.FC<{
onSuccess: async () => {
utils.team.getTeamUsers.invalidate();
setOpen(false);
toast.success("Team member removed successfully");
toast.success('Team member removed successfully');
},
onError: async (error) => {
toast.error(error.message);
@@ -50,24 +50,24 @@ export const DeleteTeamMember: React.FC<{
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
{self ? (
<LogOut className="h-4 w-4 text-red/80" />
<LogOut className="text-red/80 h-4 w-4" />
) : (
<Trash2 className="h-4 w-4 text-red/80" />
<Trash2 className="text-red/80 h-4 w-4" />
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{self ? "Leave Team" : "Remove Team Member"}
{self ? 'Leave Team' : 'Remove Team Member'}
</DialogTitle>
<DialogDescription>
{self
? "Are you sure you want to leave the team? This action cannot be undone."
? 'Are you sure you want to leave the team? This action cannot be undone.'
: `Are you sure you want to remove ${teamUser.email} from the team? This action cannot be undone.`}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4 mt-6">
<div className="mt-6 flex justify-end gap-4">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
@@ -77,7 +77,7 @@ export const DeleteTeamMember: React.FC<{
isLoading={deleteTeamUserMutation.isPending}
className="w-[150px]"
>
{self ? "Leave" : "Remove"}
{self ? 'Leave' : 'Remove'}
</Button>
</div>
</DialogContent>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { Button } from '@usesend/ui/src/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Form,
FormControl,
@@ -15,26 +15,26 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
} from '@usesend/ui/src/form';
import { api } from "~/trpc/react";
import { useState } from "react";
import { PencilIcon } from "lucide-react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@usesend/ui/src/toaster";
import { Role } from "@prisma/client";
import { api } from '~/trpc/react';
import { useState } from 'react';
import { PencilIcon } from 'lucide-react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from '@usesend/ui/src/toaster';
import { Role } from '@prisma/client';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
const teamUserSchema = z.object({
role: z.enum(["MEMBER", "ADMIN"]),
role: z.enum(['MEMBER', 'ADMIN']),
});
export const EditTeamMember: React.FC<{
@@ -62,12 +62,12 @@ export const EditTeamMember: React.FC<{
onSuccess: async () => {
utils.team.getTeamUsers.invalidate();
setOpen(false);
toast.success("Team member role updated successfully");
toast.success('Team member role updated successfully');
},
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}

View File

@@ -1,28 +1,28 @@
"use client";
'use client';
import { useState } from "react";
import { Button } from "@usesend/ui/src/button";
import { PlusIcon } from "lucide-react";
import { useState } from 'react';
import { Button } from '@usesend/ui/src/button';
import { PlusIcon } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@usesend/ui/src/dialog";
} from '@usesend/ui/src/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { Input } from "@usesend/ui/src/input";
import { useForm } from "react-hook-form";
import { api } from "~/trpc/react";
import { toast } from "@usesend/ui/src/toaster";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
} from '@usesend/ui/src/select';
import { Input } from '@usesend/ui/src/input';
import { useForm } from 'react-hook-form';
import { api } from '~/trpc/react';
import { toast } from '@usesend/ui/src/toaster';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form,
FormControl,
@@ -31,18 +31,18 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { useTeam } from "~/providers/team-context";
import { isCloud, isSelfHosted } from "~/utils/common";
import { useUpgradeModalStore } from "~/store/upgradeModalStore";
import { LimitReason } from "~/lib/constants/plans";
} from '@usesend/ui/src/form';
import { useTeam } from '~/providers/team-context';
import { isCloud, isSelfHosted } from '~/utils/common';
import { useUpgradeModalStore } from '~/store/upgradeModalStore';
import { LimitReason } from '~/lib/constants/plans';
const inviteTeamMemberSchema = z.object({
email: z
.string({ required_error: "Email is required" })
.email("Invalid email address"),
role: z.enum(["ADMIN", "MEMBER"], {
required_error: "Please select a role",
.string({ required_error: 'Email is required' })
.email('Invalid email address'),
role: z.enum(['ADMIN', 'MEMBER'], {
required_error: 'Please select a role',
}),
});
@@ -62,8 +62,8 @@ export default function InviteTeamMember() {
const form = useForm<FormData>({
resolver: zodResolver(inviteTeamMemberSchema),
defaultValues: {
email: "",
role: "MEMBER",
email: '',
role: 'MEMBER',
},
});
@@ -88,11 +88,11 @@ export default function InviteTeamMember() {
form.reset();
setOpen(false);
void utils.team.getTeamInvites.invalidate();
toast.success("Invitation sent successfully");
toast.success('Invitation sent successfully');
},
onError: (error) => {
console.error(error);
toast.error(error.message || "Failed to send invitation");
toast.error(error.message || 'Failed to send invitation');
},
},
);
@@ -106,8 +106,8 @@ export default function InviteTeamMember() {
createInvite.mutate(
{
email: form.getValues("email"),
role: form.getValues("role"),
email: form.getValues('email'),
role: form.getValues('role'),
sendEmail: false,
},
{
@@ -118,11 +118,11 @@ export default function InviteTeamMember() {
);
form.reset();
setOpen(false);
toast.success("Invitation link copied to clipboard");
toast.success('Invitation link copied to clipboard');
},
onError: (error) => {
console.error(error);
toast.error(error.message || "Failed to copy invitation link");
toast.error(error.message || 'Failed to copy invitation link');
},
},
);
@@ -152,7 +152,7 @@ export default function InviteTeamMember() {
Invite Member
</Button>
</DialogTrigger>
<DialogContent className=" max-w-lg">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
</DialogHeader>
@@ -197,13 +197,13 @@ export default function InviteTeamMember() {
<SelectContent>
<SelectItem value="ADMIN">
<div>Admin</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
Manage users, update payments
</div>
</SelectItem>
<SelectItem value="MEMBER">
<div>Member</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
Manage emails, domains and contacts
</div>
</SelectItem>
@@ -214,8 +214,8 @@ export default function InviteTeamMember() {
)}
/>
{isSelfHosted() && domains?.length ? (
<div className="text-sm text-muted-foreground">
Will use{" "}
<div className="text-muted-foreground text-sm">
Will use{' '}
<span className="font-bold">hello@{domains[0]?.name}</span> to
send invitation
</div>

View File

@@ -1,12 +1,12 @@
"use client";
'use client';
import InviteTeamMember from "./invite-team-member";
import TeamMembersList from "./team-members-list";
import InviteTeamMember from './invite-team-member';
import TeamMembersList from './team-members-list';
export default function TeamsPage() {
return (
<div>
<div className="flex justify-end ">
<div className="flex justify-end">
<InviteTeamMember />
</div>
<TeamMembersList />

View File

@@ -1,16 +1,16 @@
"use client";
'use client';
import { Button } from "@usesend/ui/src/button";
import { api } from "~/trpc/react";
import { toast } from "@usesend/ui/src/toaster";
import { Copy, RotateCw } from "lucide-react";
import { Button } from '@usesend/ui/src/button';
import { api } from '~/trpc/react';
import { toast } from '@usesend/ui/src/toaster';
import { Copy, RotateCw } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@usesend/ui/src/tooltip";
import { isSelfHosted } from "~/utils/common";
} from '@usesend/ui/src/tooltip';
import { isSelfHosted } from '~/utils/common';
export const ResendTeamInvite: React.FC<{
invite: { id: string; email: string };
@@ -29,7 +29,7 @@ export const ResendTeamInvite: React.FC<{
onError: async (error) => {
toast.error(error.message);
},
}
},
);
}
@@ -54,7 +54,7 @@ export const ResendTeamInvite: React.FC<{
size="sm"
onClick={() => {
navigator.clipboard.writeText(
`${location.origin}/join-team?inviteId=${invite.id}`
`${location.origin}/join-team?inviteId=${invite.id}`,
);
toast.success(`Invite link copied to clipboard`);
}}

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Table,
@@ -7,18 +7,18 @@ import {
TableHead,
TableBody,
TableCell,
} from "@usesend/ui/src/table";
import { api } from "~/trpc/react";
import { Button } from "@usesend/ui/src/button";
import Spinner from "@usesend/ui/src/spinner";
import { formatDistanceToNow } from "date-fns";
import { Role } from "@prisma/client";
import { EditTeamMember } from "./edit-team-member";
import { DeleteTeamMember } from "./delete-team-member";
import { ResendTeamInvite } from "./resend-team-invite";
import { DeleteTeamInvite } from "./delete-team-invite";
import { useTeam } from "~/providers/team-context";
import { useSession } from "next-auth/react";
} from '@usesend/ui/src/table';
import { api } from '~/trpc/react';
import { Button } from '@usesend/ui/src/button';
import Spinner from '@usesend/ui/src/spinner';
import { formatDistanceToNow } from 'date-fns';
import { Role } from '@prisma/client';
import { EditTeamMember } from './edit-team-member';
import { DeleteTeamMember } from './delete-team-member';
import { ResendTeamInvite } from './resend-team-invite';
import { DeleteTeamInvite } from './delete-team-invite';
import { useTeam } from '~/providers/team-context';
import { useSession } from 'next-auth/react';
export default function TeamMembersList() {
const { currentIsAdmin } = useTeam();
@@ -34,7 +34,7 @@ export default function TeamMembersList() {
return (
<div className="mt-10 flex flex-col gap-4">
<div className="flex flex-col rounded-xl border border-border shadow">
<div className="border-border flex flex-col rounded-xl border shadow">
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
@@ -48,9 +48,9 @@ export default function TeamMembersList() {
<TableBody>
{isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={5} className="text-center py-4">
<TableCell colSpan={5} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
@@ -59,15 +59,15 @@ export default function TeamMembersList() {
teamMembers.map((member) => (
<TableRow key={member.userId} className="">
<TableCell className="font-medium">
{member.user?.email || "Unknown user"}
{member.user?.email || 'Unknown user'}
</TableCell>
<TableCell>
<div className=" rounded capitalize py-1 text-xs">
<div className="rounded py-1 text-xs capitalize">
{member.role.toLowerCase()}
</div>
</TableCell>
<TableCell>
<div className="text-center w-[100px] rounded capitalize py-1 text-xs bg-green/15 text-green border border-green/25">
<div className="bg-green/15 text-green border-green/25 w-[100px] rounded border py-1 text-center text-xs capitalize">
Active
</div>
</TableCell>
@@ -91,7 +91,7 @@ export default function TeamMembersList() {
teamUser={{
userId: String(member.userId),
role: member.role,
email: member.user?.email || "Unknown user",
email: member.user?.email || 'Unknown user',
}}
self={session?.user.id == member.userId}
/>
@@ -102,7 +102,7 @@ export default function TeamMembersList() {
))
) : (
<TableRow className="h-32">
<TableCell colSpan={5} className="text-center py-4">
<TableCell colSpan={5} className="py-4 text-center">
No team members found
</TableCell>
</TableRow>
@@ -117,12 +117,12 @@ export default function TeamMembersList() {
{invite.email}
</TableCell>
<TableCell>
<div className=" w-[100px] rounded capitalize py-1 text-xs">
<div className="w-[100px] rounded py-1 text-xs capitalize">
{invite.role.toLowerCase()}
</div>
</TableCell>
<TableCell>
<div className="text-center w-[100px] rounded capitalize py-1 text-xs bg-yellow/15 text-yellow border border-yellow/25">
<div className="bg-yellow/15 text-yellow border-yellow/25 w-[100px] rounded border py-1 text-center text-xs capitalize">
Pending
</div>
</TableCell>

View File

@@ -1,20 +1,20 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Card } from "@usesend/ui/src/card";
import Spinner from "@usesend/ui/src/spinner";
import { format } from "date-fns";
import { api } from '~/trpc/react';
import { Card } from '@usesend/ui/src/card';
import Spinner from '@usesend/ui/src/spinner';
import { format } from 'date-fns';
import {
getCost,
PLAN_CREDIT_UNITS,
UNIT_PRICE,
USAGE_UNIT_PRICE,
} from "~/lib/usage";
import { useTeam } from "~/providers/team-context";
import { EmailUsageType } from "@prisma/client";
import { PlanDetails } from "~/components/payments/PlanDetails";
import { UpgradeButton } from "~/components/payments/UpgradeButton";
import { Progress } from "@usesend/ui/src/progress";
} from '~/lib/usage';
import { useTeam } from '~/providers/team-context';
import { EmailUsageType } from '@prisma/client';
import { PlanDetails } from '~/components/payments/PlanDetails';
import { UpgradeButton } from '~/components/payments/UpgradeButton';
import { Progress } from '@usesend/ui/src/progress';
const FREE_PLAN_LIMIT = 3000;
@@ -36,20 +36,20 @@ function FreePlanUsage({
return (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
<div className="w-full space-y-4">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
{item.type === "TRANSACTIONAL"
? "Mails sent using the send api or SMTP"
: "Mails designed sent from useSend editor"}
<div className="text-muted-foreground mt-1 text-sm">
{item.type === 'TRANSACTIONAL'
? 'Mails sent using the send api or SMTP'
: 'Mails designed sent from useSend editor'}
</div>
</div>
<div className="font-mono font-medium">
@@ -57,30 +57,30 @@ function FreePlanUsage({
</div>
</div>
))}
<div className="flex justify-between items-center pt-3 ">
<div className="flex items-center justify-between pt-3">
<div className="font-medium">Total</div>
<div className="font-mono font-medium">
{usage
?.reduce((acc, item) => acc + item.sent, 0)
.toLocaleString()}{" "}
.toLocaleString()}{' '}
emails
</div>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div className="flex w-full items-center justify-center">
<div className="w-[300px] space-y-8">
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="">Monthly Limit</div>
<div className="font-mono font-medium">
{totalSent.toLocaleString()}/
{FREE_PLAN_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div className="bg-secondary h-2 overflow-hidden rounded-full">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
className="bg-primary h-full transition-all duration-300 ease-in-out"
style={{
width: `${Math.min(monthlyPercentageUsed, 100)}%`,
}}
@@ -91,15 +91,15 @@ function FreePlanUsage({
<div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
<div className="">Daily Limit</div>
<div className="font-mono">
{dailyUsage.toLocaleString()}/{DAILY_LIMIT.toLocaleString()}
</div>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div className="bg-secondary h-2 overflow-hidden rounded-full">
<div
className="h-full bg-primary transition-all duration-300 ease-in-out"
className="bg-primary h-full transition-all duration-300 ease-in-out"
style={{ width: `${Math.min(dailyPercentageUsed, 100)}%` }}
/>
</div>
@@ -119,7 +119,7 @@ function PaidPlanUsage({
}) {
const { currentTeam } = useTeam();
if (currentTeam?.plan === "FREE") return null;
if (currentTeam?.plan === 'FREE') return null;
const totalCost =
usage?.reduce((acc, item) => acc + getCost(item.sent, item.type), 0) || 0;
@@ -128,24 +128,24 @@ function PaidPlanUsage({
return (
<Card className="p-6">
<div className="flex w-full">
<div className="space-y-4 w-full">
<div className="w-full space-y-4">
{usage?.map((item) => (
<div
key={item.type}
className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0"
className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0"
>
<div>
<div className="font-medium capitalize">
{item.type.toLowerCase()}
</div>
<div className="text-sm text-muted-foreground mt-1">
<div className="text-muted-foreground mt-1 text-sm">
<span className="font-mono">
{item.sent.toLocaleString()}
</span>{" "}
emails at{" "}
</span>{' '}
emails at{' '}
<span className="font-mono">
${USAGE_UNIT_PRICE[item.type]}
</span>{" "}
</span>{' '}
each
</div>
</div>
@@ -155,16 +155,16 @@ function PaidPlanUsage({
</div>
))}
<div>
<div className="flex justify-between items-center border-b pb-3 last:border-0 last:pb-0">
<div className="flex items-center justify-between border-b pb-3 last:border-0 last:pb-0">
<div>
<div className="font-medium capitalize">Available credit</div>
<div className="text-sm text-muted-foreground mt-1">
<div className="text-muted-foreground mt-1 text-sm">
{currentTeam?.plan}
</div>
</div>
<div className="font-mono font-medium">
{totalCost > planCreditCost
? "0"
? '0'
: `$${(planCreditCost - totalCost).toFixed(2)}`}
</div>
</div>
@@ -173,11 +173,11 @@ function PaidPlanUsage({
/>
</div>
</div>
<div className="w-full flex justify-center items-center">
<div className="flex w-full items-center justify-center">
<div>
<div className="font-medium">Amount Due</div>
<div className="">
<div className="text-2xl font-mono">
<div className="font-mono text-2xl">
{planCreditCost < totalCost
? `$${(totalCost - planCreditCost).toFixed(2)}`
: `$${(0.0).toFixed(2)}`}
@@ -200,8 +200,8 @@ export default function UsagePage() {
const today = new Date();
const billingPeriod =
subscription?.currentPeriodStart && subscription?.currentPeriodEnd
? `${format(new Date(subscription.currentPeriodStart), "MMM dd")} - ${format(new Date(subscription.currentPeriodEnd), "MMM dd")}`
: `${format(new Date(today.getFullYear(), today.getMonth(), 1), "MMM dd")} - ${format(new Date(today.getFullYear(), today.getMonth() + 1, 1), "MMM dd")}`;
? `${format(new Date(subscription.currentPeriodStart), 'MMM dd')} - ${format(new Date(subscription.currentPeriodEnd), 'MMM dd')}`
: `${format(new Date(today.getFullYear(), today.getMonth(), 1), 'MMM dd')} - ${format(new Date(today.getFullYear(), today.getMonth() + 1, 1), 'MMM dd')}`;
return (
<div>
@@ -209,7 +209,7 @@ export default function UsagePage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Usage</h1>
<div className="text-sm text-muted-foreground mt-1">
<div className="text-muted-foreground mt-1 text-sm">
<span className="font-medium">{billingPeriod}</span>
</div>
</div>
@@ -217,13 +217,13 @@ export default function UsagePage() {
{isLoading ? (
<div className="flex justify-center py-8">
<Spinner className="w-8 h-8" innerSvgClass="stroke-primary" />
<Spinner className="h-8 w-8" innerSvgClass="stroke-primary" />
</div>
) : usage?.month.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
<Card className="text-muted-foreground p-6 text-center">
No usage data available
</Card>
) : currentTeam?.plan === "FREE" ? (
) : currentTeam?.plan === 'FREE' ? (
<FreePlanUsage
usage={usage?.month ?? []}
dayUsage={usage?.day ?? []}
@@ -233,10 +233,10 @@ export default function UsagePage() {
)}
</div>
{currentTeam?.plan ? (
<Card className=" rounded-xl mt-10 p-4 px-8">
<Card className="mt-10 rounded-xl p-4 px-8">
<PlanDetails />
<div className="mt-4">
{currentTeam?.plan === "FREE" ? <UpgradeButton /> : null}
{currentTeam?.plan === 'FREE' ? <UpgradeButton /> : null}
</div>
</Card>
) : null}

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { useState } from "react";
import { api } from "~/trpc/react";
import { SuppressionReason } from "@prisma/client";
import { useState } from 'react';
import { api } from '~/trpc/react';
import { SuppressionReason } from '@prisma/client';
import {
Dialog,
DialogContent,
@@ -10,17 +10,17 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { Label } from "@usesend/ui/src/label";
} from '@usesend/ui/src/dialog';
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import { Label } from '@usesend/ui/src/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
interface AddSuppressionDialogProps {
open: boolean;
@@ -31,9 +31,9 @@ export default function AddSuppressionDialog({
open,
onOpenChange,
}: AddSuppressionDialogProps) {
const [email, setEmail] = useState("");
const [email, setEmail] = useState('');
const [reason, setReason] = useState<SuppressionReason>(
SuppressionReason.MANUAL
SuppressionReason.MANUAL,
);
const [error, setError] = useState<string | null>(null);
@@ -54,11 +54,11 @@ export default function AddSuppressionDialog({
{ email: email.trim() },
{
enabled: false,
}
},
);
const handleClose = () => {
setEmail("");
setEmail('');
setReason(SuppressionReason.MANUAL);
setError(null);
onOpenChange(false);
@@ -71,14 +71,14 @@ export default function AddSuppressionDialog({
const trimmedEmail = email.trim().toLowerCase();
if (!trimmedEmail) {
setError("Email address is required");
setError('Email address is required');
return;
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmedEmail)) {
setError("Please enter a valid email address");
setError('Please enter a valid email address');
return;
}
@@ -86,7 +86,7 @@ export default function AddSuppressionDialog({
try {
const { data: isAlreadySuppressed } = await checkMutation.refetch();
if (isAlreadySuppressed) {
setError("This email is already suppressed");
setError('This email is already suppressed');
return;
}
} catch (error) {
@@ -142,7 +142,7 @@ export default function AddSuppressionDialog({
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
<div className="text-destructive bg-destructive/10 rounded-md p-3 text-sm">
{error}
</div>
)}
@@ -160,7 +160,7 @@ export default function AddSuppressionDialog({
type="submit"
disabled={addMutation.isPending || !email.trim()}
>
{addMutation.isPending ? "Adding..." : "Add Suppression"}
{addMutation.isPending ? 'Adding...' : 'Add Suppression'}
</Button>
</DialogFooter>
</form>

View File

@@ -1,8 +1,8 @@
"use client";
'use client';
import { useState } from "react";
import { api } from "~/trpc/react";
import { SuppressionReason } from "@prisma/client";
import { useState } from 'react';
import { api } from '~/trpc/react';
import { SuppressionReason } from '@prisma/client';
import {
Dialog,
DialogContent,
@@ -10,19 +10,19 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import { Button } from "@usesend/ui/src/button";
import { Label } from "@usesend/ui/src/label";
import { Textarea } from "@usesend/ui/src/textarea";
} from '@usesend/ui/src/dialog';
import { Button } from '@usesend/ui/src/button';
import { Label } from '@usesend/ui/src/label';
import { Textarea } from '@usesend/ui/src/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@usesend/ui/src/tabs";
import { Upload, FileText } from "lucide-react";
} from '@usesend/ui/src/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@usesend/ui/src/tabs';
import { Upload, FileText } from 'lucide-react';
interface BulkAddSuppressionsDialogProps {
open: boolean;
@@ -33,9 +33,9 @@ export default function BulkAddSuppressionsDialog({
open,
onOpenChange,
}: BulkAddSuppressionsDialogProps) {
const [emails, setEmails] = useState("");
const [emails, setEmails] = useState('');
const [reason, setReason] = useState<SuppressionReason>(
SuppressionReason.MANUAL
SuppressionReason.MANUAL,
);
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
@@ -57,7 +57,7 @@ export default function BulkAddSuppressionsDialog({
});
const handleClose = () => {
setEmails("");
setEmails('');
setReason(SuppressionReason.MANUAL);
setError(null);
setProcessing(false);
@@ -69,7 +69,7 @@ export default function BulkAddSuppressionsDialog({
const emailList = text
.split(/[\n,;]+/)
.map((email) => email.trim().toLowerCase())
.filter((email) => email && email.includes("@"));
.filter((email) => email && email.includes('@'));
// Remove duplicates
return Array.from(new Set(emailList));
@@ -82,8 +82,8 @@ export default function BulkAddSuppressionsDialog({
const processFile = (file: File) => {
// Validate file type
if (!file.name.endsWith(".txt") && !file.name.endsWith(".csv")) {
setError("Please upload a .txt or .csv file");
if (!file.name.endsWith('.txt') && !file.name.endsWith('.csv')) {
setError('Please upload a .txt or .csv file');
return;
}
@@ -131,7 +131,7 @@ export default function BulkAddSuppressionsDialog({
setProcessing(true);
if (!emails.trim()) {
setError("Please enter email addresses");
setError('Please enter email addresses');
setProcessing(false);
return;
}
@@ -139,7 +139,7 @@ export default function BulkAddSuppressionsDialog({
const emailList = parseEmails(emails);
if (emailList.length === 0) {
setError("No valid email addresses found");
setError('No valid email addresses found');
setProcessing(false);
return;
}
@@ -147,13 +147,13 @@ export default function BulkAddSuppressionsDialog({
const validEmails = validateEmails(emailList);
if (validEmails.length === 0) {
setError("No valid email addresses found");
setError('No valid email addresses found');
setProcessing(false);
return;
}
if (validEmails.length > 1000) {
setError("Maximum 1000 email addresses allowed per upload");
setError('Maximum 1000 email addresses allowed per upload');
setProcessing(false);
return;
}
@@ -191,11 +191,11 @@ export default function BulkAddSuppressionsDialog({
<Tabs defaultValue="text" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="text">
<FileText className="h-4 w-4 mr-2" />
<FileText className="mr-2 h-4 w-4" />
Text Input
</TabsTrigger>
<TabsTrigger value="file">
<Upload className="h-4 w-4 mr-2" />
<Upload className="mr-2 h-4 w-4" />
File Upload
</TabsTrigger>
</TabsList>
@@ -218,10 +218,10 @@ export default function BulkAddSuppressionsDialog({
<div className="space-y-2">
<Label htmlFor="file">Upload File</Label>
<div
className={`border-2 border-dashed rounded-lg p-6 transition-colors ${
className={`rounded-lg border-2 border-dashed p-6 transition-colors ${
isDragOver
? "border-primary bg-primary/5"
: "border-muted-foreground/25"
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -238,23 +238,23 @@ export default function BulkAddSuppressionsDialog({
<div className="text-center">
<Upload
className={`mx-auto h-12 w-12 ${
isDragOver ? "text-primary" : "text-muted-foreground"
isDragOver ? 'text-primary' : 'text-muted-foreground'
}`}
/>
<div className="mt-2">
<Button
type="button"
variant="outline"
onClick={() => document.getElementById("file")?.click()}
onClick={() => document.getElementById('file')?.click()}
disabled={processing}
>
Choose File
</Button>
</div>
<p className="mt-2 text-sm text-muted-foreground">
<p className="text-muted-foreground mt-2 text-sm">
{isDragOver
? "Drop your file here"
: "Upload a .txt or .csv file with email addresses or drag and drop here"}
? 'Drop your file here'
: 'Upload a .txt or .csv file with email addresses or drag and drop here'}
</p>
</div>
</div>
@@ -289,7 +289,7 @@ export default function BulkAddSuppressionsDialog({
</div>
{emailList.length > 0 && (
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
<div className="text-muted-foreground bg-muted/50 rounded-md p-3 text-sm">
<div>Found {emailList.length} email addresses</div>
<div>Valid: {validEmails.length}</div>
{validEmails.length !== emailList.length && (
@@ -301,7 +301,7 @@ export default function BulkAddSuppressionsDialog({
)}
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
<div className="text-destructive bg-destructive/10 rounded-md p-3 text-sm">
{error}
</div>
)}
@@ -320,7 +320,7 @@ export default function BulkAddSuppressionsDialog({
disabled={processing || validEmails.length === 0}
>
{processing
? "Adding..."
? 'Adding...'
: `Add ${validEmails.length} Suppressions`}
</Button>
</DialogFooter>

View File

@@ -1,13 +1,13 @@
"use client";
'use client';
import { useState } from "react";
import AddSuppressionDialog from "./add-suppression";
import BulkAddSuppressionsDialog from "./bulk-add-suppressions";
import SuppressionList from "./suppression-list";
import SuppressionStats from "./suppression-stats";
import { Button } from "@usesend/ui/src/button";
import { Plus, Upload } from "lucide-react";
import { H1 } from "@usesend/ui";
import { useState } from 'react';
import AddSuppressionDialog from './add-suppression';
import BulkAddSuppressionsDialog from './bulk-add-suppressions';
import SuppressionList from './suppression-list';
import SuppressionStats from './suppression-stats';
import { Button } from '@usesend/ui/src/button';
import { Plus, Upload } from 'lucide-react';
import { H1 } from '@usesend/ui';
export default function SuppressionsPage() {
const [showAddDialog, setShowAddDialog] = useState(false);
@@ -16,15 +16,15 @@ export default function SuppressionsPage() {
return (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-10">
<div className="mb-10 flex items-center justify-between">
<H1>Suppression List</H1>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowBulkAddDialog(true)}>
<Upload className="h-4 w-4 mr-2" />
<Upload className="mr-2 h-4 w-4" />
Bulk Add
</Button>
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
<Plus className="mr-2 h-4 w-4" />
Add Suppression
</Button>
</div>

View File

@@ -1,4 +1,4 @@
"use client";
'use client';
import {
Dialog,
@@ -7,8 +7,8 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import { Button } from "@usesend/ui/src/button";
} from '@usesend/ui/src/dialog';
import { Button } from '@usesend/ui/src/button';
interface RemoveSuppressionDialogProps {
email: string | null;
@@ -49,7 +49,7 @@ export default function RemoveSuppressionDialog({
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? "Removing..." : "Remove"}
{isLoading ? 'Removing...' : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,20 +1,20 @@
"use client";
'use client';
import { useState } from "react";
import { api } from "~/trpc/react";
import { useUrlState } from "~/hooks/useUrlState";
import { useDebouncedCallback } from "use-debounce";
import { SuppressionReason } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@usesend/ui/src/button";
import { Input } from "@usesend/ui/src/input";
import { useState } from 'react';
import { api } from '~/trpc/react';
import { useUrlState } from '~/hooks/useUrlState';
import { useDebouncedCallback } from 'use-debounce';
import { SuppressionReason } from '@prisma/client';
import { formatDistanceToNow } from 'date-fns';
import { Button } from '@usesend/ui/src/button';
import { Input } from '@usesend/ui/src/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
} from '@usesend/ui/src/select';
import {
Table,
TableBody,
@@ -22,30 +22,30 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@usesend/ui/src/table";
import { Trash2, Download } from "lucide-react";
import RemoveSuppressionDialog from "./remove-suppression";
import Spinner from "@usesend/ui/src/spinner";
} from '@usesend/ui/src/table';
import { Trash2, Download } from 'lucide-react';
import RemoveSuppressionDialog from './remove-suppression';
import Spinner from '@usesend/ui/src/spinner';
const reasonLabels = {
HARD_BOUNCE: "Hard Bounce",
COMPLAINT: "Complaint",
MANUAL: "Manual",
HARD_BOUNCE: 'Hard Bounce',
COMPLAINT: 'Complaint',
MANUAL: 'Manual',
} as const;
export default function SuppressionList() {
const [search, setSearch] = useUrlState("search");
const [reason, setReason] = useUrlState("reason");
const [page, setPage] = useUrlState("page", "1");
const [search, setSearch] = useUrlState('search');
const [reason, setReason] = useUrlState('reason');
const [page, setPage] = useUrlState('page', '1');
const [emailToRemove, setEmailToRemove] = useState<string | null>(null);
const suppressionsQuery = api.suppression.getSuppressions.useQuery({
page: parseInt(page || "1"),
page: parseInt(page || '1'),
limit: 20,
search: search || undefined,
reason: reason as SuppressionReason | undefined,
sortBy: "createdAt",
sortOrder: "desc",
sortBy: 'createdAt',
sortOrder: 'desc',
});
const exportQuery = api.suppression.exportSuppressions.useQuery(
@@ -53,7 +53,7 @@ export default function SuppressionList() {
search: search || undefined,
reason: reason as SuppressionReason | undefined,
},
{ enabled: false }
{ enabled: false },
);
const utils = api.useUtils();
@@ -68,12 +68,12 @@ export default function SuppressionList() {
const debouncedSearch = useDebouncedCallback((value: string) => {
setSearch(value || null);
setPage("1");
setPage('1');
}, 1000);
const handleReasonFilter = (value: string) => {
setReason(value === "all" ? null : value);
setPage("1");
setReason(value === 'all' ? null : value);
setPage('1');
};
const handleExport = async () => {
@@ -81,18 +81,18 @@ export default function SuppressionList() {
if (resp.data) {
const csv = [
"Email,Reason,Created At",
'Email,Reason,Created At',
...resp.data.map(
(suppression) =>
`${suppression.email},${suppression.reason},${suppression.createdAt}`
`${suppression.email},${suppression.reason},${suppression.createdAt}`,
),
].join("\n");
].join('\n');
const blob = new Blob([csv], { type: "text/csv" });
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
const a = document.createElement('a');
a.href = url;
a.download = `suppressions-${new Date().toISOString().split("T")[0]}.csv`;
a.download = `suppressions-${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -113,16 +113,16 @@ export default function SuppressionList() {
return (
<div className="mt-10 flex flex-col gap-4">
{/* Header and Export */}
<div className="flex justify-between items-center">
<div className="flex items-center justify-between">
{/* Filters */}
<div className="flex gap-4">
<Input
placeholder="Search by email address..."
className="max-w-sm"
defaultValue={search || ""}
defaultValue={search || ''}
onChange={(e) => debouncedSearch(e.target.value)}
/>
<Select value={reason || "all"} onValueChange={handleReasonFilter}>
<Select value={reason || 'all'} onValueChange={handleReasonFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by reason" />
</SelectTrigger>
@@ -133,13 +133,13 @@ export default function SuppressionList() {
<SelectItem value="MANUAL">Manual</SelectItem>
</SelectContent>
</Select>
</div>{" "}
</div>{' '}
<Button
variant="outline"
onClick={handleExport}
disabled={exportQuery.isFetching}
>
<Download className="h-4 w-4 mr-2" />
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
@@ -148,7 +148,7 @@ export default function SuppressionList() {
<div className="flex flex-col rounded-xl border shadow">
<Table className="">
<TableHeader className="">
<TableRow className=" bg-muted/30">
<TableRow className="bg-muted/30">
<TableHead className="rounded-tl-xl">Email</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Added</TableHead>
@@ -158,16 +158,16 @@ export default function SuppressionList() {
<TableBody>
{suppressionsQuery.isLoading ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
<Spinner
className="w-6 h-6 mx-auto"
className="mx-auto h-6 w-6"
innerSvgClass="stroke-primary"
/>
</TableCell>
</TableRow>
) : suppressionsQuery.data?.suppressions.length === 0 ? (
<TableRow className="h-32">
<TableCell colSpan={4} className="text-center py-4">
<TableCell colSpan={4} className="py-4 text-center">
No suppressed emails found
</TableCell>
</TableRow>
@@ -179,12 +179,12 @@ export default function SuppressionList() {
</TableCell>
<TableCell>
<div
className={`text-center w-[130px] rounded capitalize py-1 text-xs ${
suppression.reason === "HARD_BOUNCE"
? "bg-red/15 text-red border border-red/20"
: suppression.reason === "COMPLAINT"
? "bg-yellow/15 text-yellow border border-yellow/20"
: "bg-blue/15 text-blue border border-blue/20"
className={`w-[130px] rounded py-1 text-center text-xs capitalize ${
suppression.reason === 'HARD_BOUNCE'
? 'bg-red/15 text-red border-red/20 border'
: suppression.reason === 'COMPLAINT'
? 'bg-yellow/15 text-yellow border-yellow/20 border'
: 'bg-blue/15 text-blue border-blue/20 border'
}`}
>
{reasonLabels[suppression.reason]}
@@ -214,17 +214,17 @@ export default function SuppressionList() {
</div>
{/* Pagination */}
<div className="flex gap-4 justify-end">
<div className="flex justify-end gap-4">
<Button
size="sm"
onClick={() => setPage(String(parseInt(page || "1") - 1))}
disabled={parseInt(page || "1") === 1}
onClick={() => setPage(String(parseInt(page || '1') - 1))}
disabled={parseInt(page || '1') === 1}
>
Previous
</Button>
<Button
size="sm"
onClick={() => setPage(String(parseInt(page || "1") + 1))}
onClick={() => setPage(String(parseInt(page || '1') + 1))}
disabled={!suppressionsQuery.data?.pagination?.hasNext}
>
Next

View File

@@ -1,6 +1,6 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { api } from '~/trpc/react';
export default function SuppressionStats() {
const { data: stats, isLoading } =
@@ -8,14 +8,14 @@ export default function SuppressionStats() {
if (isLoading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4 lg:gap-8">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="flex flex-col gap-2 rounded-lg border p-4 shadow"
>
<div className="h-4 bg-muted animate-pulse rounded mb-1" />
<div className="h-8 bg-muted animate-pulse rounded" />
<div className="bg-muted mb-1 h-4 animate-pulse rounded" />
<div className="bg-muted h-8 animate-pulse rounded" />
</div>
))}
</div>
@@ -27,29 +27,29 @@ export default function SuppressionStats() {
: 0;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4 lg:gap-8">
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Total Suppressions</p>
<div className="text-2xl font-mono">{totalSuppressions}</div>
<p className="mb-1 font-semibold">Total Suppressions</p>
<div className="font-mono text-2xl">{totalSuppressions}</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Hard Bounces</p>
<div className="text-2xl font-mono text-red">
<p className="mb-1 font-semibold">Hard Bounces</p>
<div className="text-red font-mono text-2xl">
{stats?.HARD_BOUNCE ?? 0}
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Complaints</p>
<div className="text-2xl font-mono text-yellow">
<p className="mb-1 font-semibold">Complaints</p>
<div className="text-yellow font-mono text-2xl">
{stats?.COMPLAINT ?? 0}
</div>
</div>
<div className="flex flex-col gap-2 rounded-lg border p-4 shadow">
<p className="font-semibold mb-1">Manual</p>
<div className="text-2xl font-mono text-blue">{stats?.MANUAL ?? 0}</div>
<p className="mb-1 font-semibold">Manual</p>
<div className="text-blue font-mono text-2xl">{stats?.MANUAL ?? 0}</div>
</div>
</div>
);

View File

@@ -1,17 +1,17 @@
"use client";
'use client';
import { api } from "~/trpc/react";
import { Spinner } from "@usesend/ui/src/spinner";
import { Input } from "@usesend/ui/src/input";
import { Editor } from "@usesend/email-editor";
import { useState } from "react";
import { Template } from "@prisma/client";
import { toast } from "@usesend/ui/src/toaster";
import { useDebouncedCallback } from "use-debounce";
import { formatDistanceToNow } from "date-fns";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { use } from "react";
import { api } from '~/trpc/react';
import { Spinner } from '@usesend/ui/src/spinner';
import { Input } from '@usesend/ui/src/input';
import { Editor } from '@usesend/email-editor';
import { useState } from 'react';
import { Template } from '@prisma/client';
import { toast } from '@usesend/ui/src/toaster';
import { useDebouncedCallback } from 'use-debounce';
import { formatDistanceToNow } from 'date-fns';
import { ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { use } from 'react';
const IMAGE_SIZE_LIMIT = 10 * 1024 * 1024;
export default function EditTemplatePage({
@@ -34,15 +34,15 @@ export default function EditTemplatePage({
if (isLoading) {
return (
<div className="flex justify-center items-center h-full">
<Spinner className="w-6 h-6" />
<div className="flex h-full items-center justify-center">
<Spinner className="h-6 w-6" />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center h-full">
<div className="flex h-full items-center justify-center">
<p className="text-red-500">Failed to load template</p>
</div>
);
@@ -96,7 +96,7 @@ function TemplateEditor({
);
}
console.log("file type: ", file.type);
console.log('file type: ', file.type);
const { uploadUrl, imageUrl } = await getUploadUrl.mutateAsync({
name: file.name,
@@ -105,21 +105,21 @@ function TemplateEditor({
});
const response = await fetch(uploadUrl, {
method: "PUT",
method: 'PUT',
body: file,
});
if (!response.ok) {
throw new Error("Failed to upload file");
throw new Error('Failed to upload file');
}
return imageUrl;
};
return (
<div className="p-4 container mx-auto">
<div className="container mx-auto p-4">
<div className="mx-auto">
<div className="mb-4 flex justify-between items-center w-[700px] mx-auto">
<div className="mx-auto mb-4 flex w-[700px] items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/templates">
<ArrowLeft className="h-4 w-4" />
@@ -128,7 +128,7 @@ function TemplateEditor({
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className=" border-0 focus:ring-0 focus:outline-none px-0.5 w-[300px]"
className="w-[300px] border-0 px-0.5 focus:outline-none focus:ring-0"
onBlur={() => {
if (name === template.name || !name) {
return;
@@ -152,20 +152,20 @@ function TemplateEditor({
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
{isSaving ? (
<div className="h-2 w-2 bg-yellow rounded-full" />
<div className="bg-yellow h-2 w-2 rounded-full" />
) : (
<div className="h-2 w-2 bg-green rounded-full" />
<div className="bg-green h-2 w-2 rounded-full" />
)}
{formatDistanceToNow(template.updatedAt) === "less than a minute"
? "just now"
{formatDistanceToNow(template.updatedAt) === 'less than a minute'
? 'just now'
: `${formatDistanceToNow(template.updatedAt)} ago`}
</div>
</div>
</div>
<div className="flex flex-col mt-4 mb-4 p-4 w-[700px] mx-auto z-50">
<div className="z-50 mx-auto mb-4 mt-4 flex w-[700px] flex-col p-4">
<div className="flex items-center gap-4">
<label className="block text-sm w-[80px] text-muted-foreground">
<label className="text-muted-foreground block w-[80px] text-sm">
Subject
</label>
<input
@@ -191,13 +191,13 @@ function TemplateEditor({
},
);
}}
className="mt-1 py-1 text-sm block w-full outline-none border-b border-transparent focus:border-border bg-transparent"
className="focus:border-border mt-1 block w-full border-b border-transparent bg-transparent py-1 text-sm outline-none"
/>
</div>
</div>
<div className=" rounded-lg bg-gray-50 w-[700px] mx-auto p-10">
<div className="w-[600px] mx-auto">
<div className="mx-auto w-[700px] rounded-lg bg-gray-50 p-10">
<div className="mx-auto w-[600px]">
<Editor
initialContent={json}
onUpdate={(content) => {
@@ -205,7 +205,7 @@ function TemplateEditor({
setIsSaving(true);
deboucedUpdateTemplate();
}}
variables={["email", "firstName", "lastName"]}
variables={['email', 'firstName', 'lastName']}
uploadImage={
template.imageUploadSupported ? handleFileChange : undefined
}

Some files were not shown because too many files have changed in this diff Show More